From 33ede464bab28cea07556ad9eda2e8dac0b5e1d0 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Tue, 15 Sep 2026 15:03:43 +0200 Subject: [PATCH] chore: remove the Docker Sandboxes AI workspace setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox setup is per-developer, not per-project: which editor and git UI belong in the image depends on who is driving it, and the same setup is wanted on projects that have no config of their own. It moves to a personal repo. Removes .sbx/, scripts/sbx.sh, docs/claude-sandboxes.md, the sbx:* package scripts, and the sandbox references in README.md and CLAUDE.md. The .gitignore entries stay — they cover generated files regardless of what generates them — but the comments naming the sandbox are dropped. The pre-commit hook keeps its RUN_PRE_COMMIT_HOOK=0 escape hatch, now described in general terms instead of naming the sandbox clone. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 6 - .hooks/pre-commit | 8 +- .sbx/Dockerfile | 66 ---- .sbx/base.md | 19 -- .sbx/clone.md | 11 - .sbx/ide-forward.js | 65 ---- .sbx/mount.md | 5 - .sbx/network-allowlist.txt | 31 -- CLAUDE.md | 6 +- README.md | 14 - docs/claude-sandboxes.md | 120 ------- package.json | 3 - scripts/sbx.sh | 644 ------------------------------------- 13 files changed, 6 insertions(+), 992 deletions(-) delete mode 100644 .sbx/Dockerfile delete mode 100644 .sbx/base.md delete mode 100644 .sbx/clone.md delete mode 100644 .sbx/ide-forward.js delete mode 100644 .sbx/mount.md delete mode 100644 .sbx/network-allowlist.txt delete mode 100644 docs/claude-sandboxes.md delete mode 100755 scripts/sbx.sh diff --git a/.gitignore b/.gitignore index 64331d694..856fd3592 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ # DHIS2 Platform node_modules -# Project-local pnpm store created when installing inside an sbx sandbox -# (node_modules on the bind mount, global store on another filesystem) .pnpm-store .d2 src/locales/* @@ -14,13 +12,9 @@ cypress/downloads .aider* .github/copilot-instructions.md .claude/settings.local.json -# Installed by `playwright-cli install --skills`, not authored here .claude/skills/playwright-cli -cypress/downloads .vscode .scannerwork opensrc docs/superpowers - -# playwright-cli session artifacts (AI sandboxes) .playwright-cli diff --git a/.hooks/pre-commit b/.hooks/pre-commit index 54e24a4fd..9cd1fe44b 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -1,10 +1,8 @@ #!/bin/sh -# The sandbox clone sets RUN_PRE_COMMIT_HOOK=0 (see scripts/sbx.sh): it verifies -# lint/types/tests itself and runs i18n extract as a final step, so this hook is -# redundant there. commit-msg still runs so the clone's messages are linted against -# the project config; pre-push never fires (the clone never pushes). Humans skip a -# hook with `git commit --no-verify`. +# Escape hatch for automated environments that already run lint, types, tests and +# i18n extract themselves; commit-msg and pre-push still run. `git commit --no-verify` +# is not a substitute — it also skips commit-msg, leaving the message unlinted. if [ "$RUN_PRE_COMMIT_HOOK" = "0" ]; then exit 0 fi diff --git a/.sbx/Dockerfile b/.sbx/Dockerfile deleted file mode 100644 index fda863da0..000000000 --- a/.sbx/Dockerfile +++ /dev/null @@ -1,66 +0,0 @@ -# syntax=docker/dockerfile:1 -# -# Custom sandbox image for the Claude AI sandboxes (see docs/claude-sandboxes.md). -# Extends the official Claude Code sandbox template with everything that does NOT depend -# on the repository lockfile: global dev tooling, a headless Chromium for browser -# automation, and the Claude plugins. Repo dependencies are still installed inside the -# running sandbox (they track the lockfile); this image only bakes the slow, -# repo-independent provisioning. -# -# Uses the docker-flavored template (not the minimal one) on purpose: mount mode overlays -# node_modules with a container-local copy via a privileged bind mount (needs CAP_SYS_ADMIN), -# and only this flavor is granted that capability — so a dependency the agent installs stays -# inside the sandbox and never reaches the host. Build/load is driven by `scripts/sbx.sh setup`. - -FROM docker/sandbox-templates:claude-code-docker - -# --- system packages (root) ------------------------------------------------- -# The GTK/X libraries are the runtime deps for Cypress/Electron e2e AND headless -# Chromium — both launch real browser processes that need them. -USER root -ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - libgtk-3-0t64 libgtk2.0-0t64 libgbm1 libnotify4 libnss3 libxss1 \ - libasound2t64 libxtst6 xauth xvfb \ - libnspr4 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \ - libxkbcommon0 libatspi2.0-0t64 libxcomposite1 libxdamage1 libxfixes3 \ - libxrandr2 libpango-1.0-0 libcairo2 \ - && rm -rf /var/lib/apt/lists/* - -# --- global npm tooling (root) ---------------------------------------------- -# The base already ships Node. pnpm: pinned to the repo's packageManager version. -# typescript-language-server + typescript: the typescript-lsp plugin shells out to them. -# @playwright/cli: the `playwright-cli` browser-automation tool (bundles its own matching -# chromium, downloaded in the agent step below). -ARG PNPM_VERSION=11.5.2 -RUN npm install -g \ - "pnpm@${PNPM_VERSION}" \ - typescript \ - typescript-language-server \ - @playwright/cli \ - && npm cache clean --force - -# Chromium's apt runtime deps, via @playwright/cli's bundled playwright. -RUN node "$(npm root -g)/@playwright/cli/node_modules/playwright/cli.js" install-deps chromium - -# --- user-scoped provisioning (agent) --------------------------------------- -USER agent - -# `playwright-cli install --skills` initializes the workspace: installs the Claude -# skill (here into /home/agent/.claude/skills → user scope, so it's available in the -# sandbox without touching the repo) and downloads the matching chromium + ffmpeg into -# /home/agent/.cache/ms-playwright. Baking the browser means no runtime download (and -# no playwright-CDN network rule). -WORKDIR /home/agent -RUN playwright-cli install --skills - -# Claude plugins baked into /home/agent/.claude so they're present without a first-run -# install step. typescript-lsp (LSP diagnostics), context7 (library docs), superpowers. -ARG MARKETPLACE=anthropics/claude-plugins-official -RUN claude plugin marketplace add "${MARKETPLACE}" \ - && claude plugin install typescript-lsp@claude-plugins-official \ - && claude plugin install context7@claude-plugins-official \ - && claude plugin install superpowers@claude-plugins-official - -WORKDIR /home/agent/workspace diff --git a/.sbx/base.md b/.sbx/base.md deleted file mode 100644 index b25ec2a99..000000000 --- a/.sbx/base.md +++ /dev/null @@ -1,19 +0,0 @@ -You are running inside an isolated Docker Sandboxes microVM with its own filesystem, network, and Docker daemon. - -You have passwordless sudo. Install OS packages with `sudo apt-get install ...` — they persist for the sandbox's lifetime. Install home-directory or user-level tools WITHOUT sudo, or they install under /root/ and will not be on your PATH. - -Environment variables do not survive between bash calls, but `/etc/sandbox-persistent.sh` is sourced before every one — append `export VAR=value` to it to make a variable stick. Never add shell completion scripts to that file (nvm's `bash_completion`, sdkman's `bash_completion.sh`): they rely on variables that only exist during tab-completion, and sourcing them before every command breaks bash silently — every command returns no output at all. If that happens, remove the offending line and restart the session. - -Project dependencies live in a container-local `node_modules` that is isolated from the host — anything installed here stays inside the sandbox and never touches the host, so `pnpm install` is always safe to run. Dependencies are set up when the sandbox is created; if the sandbox has been running for a while or the lockfile has changed, run `pnpm install` to refresh them to the current lockfile. - -Outbound network is restricted by a "balanced" policy. Permission prompts are skipped because of this isolation, not because the environment is unconditionally safe — still avoid destructive or data-exfiltrating actions. If you can't reach a resource you legitimately need, don't try to work around the restriction — ask the human to allow the host. It can be allowed on the running sandbox **without a restart**: on the host, `sbx policy allow network --sandbox ` (sandbox name from `sbx ls`) takes effect immediately. Also ask them to add the host to `.sbx/network-allowlist.txt` so future sandboxes include it — that file is only read when a sandbox is created, so editing it does not change the current sandbox. - -A blocked request comes back as HTTP 403 whose body names the rule and reason — read it rather than retrying blindly. - -This sandbox has its own localhost. To reach a service running on the host, use `host.docker.internal:`, not `localhost`. To expose a service running here to the host, ask the human to run `sbx ports --publish 8080:8080/tcp`; bind your service to `0.0.0.0` rather than `127.0.0.1` or it will not be reachable. - -GitHub access is READ-ONLY. `gh` is authenticated with a read-only token, so you can read repositories, pull requests, issues, and workflow runs (`gh pr list`, `gh pr view`, `gh api ...`, or `curl https://api.github.com/...`). You CANNOT create, edit, or merge PRs/issues, and you cannot push — those requests fail server-side by design. Read freely; don't attempt writes. - -Browser automation uses the Playwright agent CLI (`playwright-cli`), which drives a headless Chromium baked into this image. Use it to load and inspect your running app: start the dev server, then `playwright-cli open http://localhost:3000` and drive it with `playwright-cli snapshot` / `click` / `fill` / `screenshot` etc. Run `playwright-cli --help` for the full command set; the installed Playwright skills document common flows. There is no chrome-devtools MCP here. - -Prefer the superpowers skills when planning any multi-step feature, behavior change, or non-trivial task: invoke `superpowers:brainstorming` to shape the design with the user, then `superpowers:writing-plans` for the implementation plan, before writing code (superpowers is installed here). Agree the plan interactively first; once it is final the work can proceed autonomously. Superpowers writes its spec and plan into `docs/superpowers/` in the repo and commits them, so they are reviewable — in the mount they appear in the host editor live, in the clone they arrive with `git fetch`. Native plan mode is fine for quick, scoped checks; its plan files (`~/.claude/plans`) are surfaced to the host too. diff --git a/.sbx/clone.md b/.sbx/clone.md deleted file mode 100644 index dd4a89310..000000000 --- a/.sbx/clone.md +++ /dev/null @@ -1,11 +0,0 @@ -This OVERRIDES the project CLAUDE.md "do not commit" rule. That rule protects the human's live working tree and does not apply here — you are on a private, isolated clone of the repository. - -Work autonomously: create a correctly-scoped branch named `scope/task` (e.g. `fix/broken-legend`, `feat/csv-export`, `refactor/dimension-hooks`), run `pnpm test` and `pnpm lint`, and commit your progress to it as you go. Your commits are signed automatically (an SSH signing key is configured), so no signing setup is needed on your part. - -The pre-commit hook is skipped here, so as the last step before you're done, run `pnpm d2-app-scripts i18n extract` and commit any resulting `i18n/` changes (the pre-commit hook normally does this on every commit; here it only needs to be correct in your final commit). The commit-msg hook still runs, so each commit message must pass the project's commitlint config. - -Git reads are fine: you CAN `git fetch`/`git pull` from `origin` (GitHub, public repo, no credentials) — e.g. `git fetch origin master` to branch off the latest master. You must NOT push: pushing to forge remotes is off-limits (there are no push credentials, and the GitHub token is read-only). - -How your work reaches the human: this sandbox publishes its repository back to the host over a read-only git remote, and the human fetches your branch from it to review — so committing to your branch is all that is needed. - -A read-only copy of the host's working tree is also at /run/sandbox/source for any UNPUSHED local changes; pull them with `git pull /run/sandbox/source `. diff --git a/.sbx/ide-forward.js b/.sbx/ide-forward.js deleted file mode 100644 index 15085d9bc..000000000 --- a/.sbx/ide-forward.js +++ /dev/null @@ -1,65 +0,0 @@ -const net = require('net') - -// Forward sandbox 127.0.0.1: to the host editor's loopback WS at the same -// port. The sandbox reaches the host only through its egress proxy, so tunnel via -// HTTP CONNECT to host.docker.internal (which the proxy maps to the host loopback). -const port = parseInt(process.argv[2], 10) -const proxyUrl = - process.env.https_proxy || - process.env.HTTPS_PROXY || - 'http://gateway.docker.internal:3128' -const proxy = new URL(proxyUrl) -const proxyHost = proxy.hostname || 'gateway.docker.internal' -const proxyPort = parseInt(proxy.port, 10) || 3128 -const target = `host.docker.internal:${port}` - -function handle(client) { - const upstream = net.connect(proxyPort, proxyHost) - let header = Buffer.alloc(0) - let tunneled = false - - const onHeader = (chunk) => { - header = Buffer.concat([header, chunk]) - const end = header.indexOf('\r\n\r\n') - if (end === -1) { - return - } - const status = header.subarray(0, header.indexOf('\r\n')).toString() - if (!status.includes(' 200 ')) { - client.destroy() - upstream.destroy() - return - } - tunneled = true - upstream.removeListener('data', onHeader) - const rest = header.subarray(end + 4) // bytes after the CONNECT response are tunnel data - if (rest.length) { - client.write(rest) - } - client.pipe(upstream) - upstream.pipe(client) - } - - upstream.on('connect', () => { - upstream.write(`CONNECT ${target} HTTP/1.1\r\nHost: ${target}\r\n\r\n`) - upstream.on('data', onHeader) - }) - const cleanup = () => { - client.destroy() - upstream.destroy() - } - client.on('error', cleanup) - upstream.on('error', cleanup) - client.on('close', () => { - if (!tunneled) { - upstream.destroy() - } - }) -} - -const server = net.createServer(handle) -server.on('error', (e) => { - process.stderr.write(String(e) + '\n') - process.exit(1) -}) -server.listen(port, '127.0.0.1') diff --git a/.sbx/mount.md b/.sbx/mount.md deleted file mode 100644 index 861d588be..000000000 --- a/.sbx/mount.md +++ /dev/null @@ -1,5 +0,0 @@ -These are the human's LIVE working files, bind-mounted from the host; your edits appear immediately in their editor. Run tests and builds directly: `pnpm test`, `pnpm lint`, `pnpm start`. - -DO NOT branch or commit — the human reviews your diffs and commits on the host. - -`node_modules` is a container-local overlay (for speed). A host `pnpm install` while this session is live can drop it and wedge the sandbox — commands start failing with missing modules, wrong-platform native-binary errors (e.g. esbuild), or `getcwd` / `No such file or directory (deleted)` / working-directory errors. You cannot fix this from inside the sandbox; do not keep retrying. Tell the human to exit the session and re-run `pnpm sbx:mount -- --continue`, which detects a wedged sandbox and restarts it. diff --git a/.sbx/network-allowlist.txt b/.sbx/network-allowlist.txt deleted file mode 100644 index 7beb7439f..000000000 --- a/.sbx/network-allowlist.txt +++ /dev/null @@ -1,31 +0,0 @@ -# Extra hosts the AI sandboxes may reach, added ON TOP of the Docker Sandboxes "balanced" -# default-deny egress policy. The balanced baseline already allows the common development -# ecosystem — npm/yarn, GitHub, PyPI, crates, Maven, Docker registries, apt, nodejs.org, -# SonarCloud, api.anthropic.com, and more — so this file lists only what the baseline does -# NOT cover. One host or wildcard pattern per line; blank lines and lines starting with # -# are ignored. Shared across developers via the repo. -# -# The DHIS2 instance host from cypress.env.json is added automatically at runtime, so it -# does not need to be listed here. Chromium is baked into the image, so no Playwright CDN -# is needed at runtime. - -# MCP servers (grep, context7) -mcp.grep.app -context7.com -*.context7.com - -# Cypress binary CDN (e2e install) -download.cypress.io -cdn.cypress.io - -# DHIS2 — docs, play/test instances, and API access. `**` matches any number of labels; -# a single `*` matches exactly one, so `*.dhis2.org` would cover play.dhis2.org but NOT -# dev.im.dhis2.org or e2e.im.dhis2.org. -dhis2.org -**.dhis2.org - -# Claude Code / Anthropic documentation. The bare claude.com and api.anthropic.com are in -# the balanced baseline, but the subdomains that actually serve the docs are not, and -# code.claude.com redirects through www.claude.com. -**.claude.com -docs.anthropic.com diff --git a/CLAUDE.md b/CLAUDE.md index 2b5967ebf..01ee996c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ timing-dependent hook tests need a specific setup. See the `testing-with-fake-ti - **Internationalization**: Use DHIS2 i18n utilities for all user-facing strings - **Testing environments**: Test against DHIS2 instances (development and production) - **Authentication**: Handle DHIS2 authentication and authorization properly -- **Browser testing**: On the host, drive the running app with **claude-in-chrome** (works in both terminal and desktop Claude Code; the developer sets it up via the Claude browser extension — see README). If you need browser automation on the host and it isn't connected, ask the human to set it up. In the AI sandboxes, use the `playwright-cli` tool instead (see [docs/claude-sandboxes.md](docs/claude-sandboxes.md)). Read `cypress.env.json` (gitignored) for the DHIS2 server URL and login credentials. The dev server on `localhost:3000` shows a login form requiring Server, Username, and Password +- **Browser testing**: Drive the running app with **claude-in-chrome** (works in both terminal and desktop Claude Code; the developer sets it up via the Claude browser extension — see README). If it isn't connected, ask the human to set it up. Read `cypress.env.json` (gitignored) for the DHIS2 server URL and login credentials. The dev server on `localhost:3000` shows a login form requiring Server, Username, and Password - **Deployment**: App can be deployed as both a standalone app and a plugin ## Understanding the DHIS2 Web API @@ -216,9 +216,9 @@ The following are enabled for this project via `.claude/settings.json`: - **Grep by Vercel** (`grep_*`): Fast code search across GitHub repositories. **Usage**: Trigger-based only — add `use the grep tool` to your prompts when you want cross-repo search. -**Browser automation**: On the host, use **claude-in-chrome** (terminal or desktop Claude Code; requires the developer to install and connect the Claude browser extension — see README). If you need to drive the browser on the host and it isn't set up, you may ask the human to set it up for you. In the AI sandboxes, browser automation is provided by the baked-in `playwright-cli` (see [docs/claude-sandboxes.md](docs/claude-sandboxes.md)). +**Browser automation**: Use **claude-in-chrome** (terminal or desktop Claude Code; requires the developer to install and connect the Claude browser extension — see README). If you need to drive the browser and it isn't set up, you may ask the human to set it up for you. -**GitHub**: Use the `gh` CLI via Bash for all GitHub operations (issues, PRs, code search, actions). Requires the [GitHub CLI](https://cli.github.com/) to be installed and authenticated (`gh auth login`). In the AI sandboxes `gh` is authenticated read-only (writes fail by design). +**GitHub**: Use the `gh` CLI via Bash for all GitHub operations (issues, PRs, code search, actions). Requires the [GitHub CLI](https://cli.github.com/) to be installed and authenticated (`gh auth login`). **Linting**: ESLint, Stylelint, and Prettier are run automatically via PostToolUse hooks. For manual checks, use `pnpm exec eslint `. diff --git a/README.md b/README.md index 44298798b..c4de49195 100644 --- a/README.md +++ b/README.md @@ -90,26 +90,12 @@ gh auth login # extension and connect it to Claude Code. # https://chromewebstore.google.com/detail/claude/fcoeoabgfenejglbffodgkkbkcdhcgfn # Once connected, Claude can drive your browser to load http://localhost:3000. -# (This is host-only; the AI sandboxes use playwright-cli instead — no setup needed.) # 4. Inside Claude Code, install and activate plugins /plugin install typescript-lsp@claude-plugins-official /reload-plugins ``` -### AI sandboxes (opt-in) - -Two optional, **experimental**, isolated AI workspaces built on [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (`sbx`) — a hands-on **mount** (your live working tree) and an autonomous **clone**. Fully opt-in: if you do not install `sbx`, nothing here affects you. (Tested on macOS/arm64 + Neovim; see the guide for portability.) - -One-time setup needs the `sbx` CLI, a read-only GitHub token, and a dedicated SSH signing key (all covered in the guide). With that done, launch one with: - -```bash -pnpm sbx:mount # hands-on, edits your live files -pnpm sbx:clone # autonomous, isolated clone -``` - -See **[docs/claude-sandboxes.md](docs/claude-sandboxes.md)** for installation and setup, mount vs clone, the `node_modules` overlay, Neovim integration, browser automation, and the full workflow. - ### Development Workflow 1. **Create a feature branch** from `main` diff --git a/docs/claude-sandboxes.md b/docs/claude-sandboxes.md deleted file mode 100644 index 6360b8feb..000000000 --- a/docs/claude-sandboxes.md +++ /dev/null @@ -1,120 +0,0 @@ -# Claude AI sandboxes (opt-in) - -Two optional, isolated AI workspaces built on [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/) (`sbx`). They are fully opt-in — if you do not install `sbx`, nothing here affects you. - -> **Highly experimental.** The design _should_ work on macOS, Linux, and Windows (via WSL) hosts, and with any IDE that integrates with Claude Code — but it has only been **tested on a macOS / Apple Silicon (arm64) host with Neovim**. Intel Macs are not supported. Because the whole feature is opt-in, none of this affects anyone who doesn't run `sbx`. Expect rough edges off the tested path; reports/fixes welcome. - -## How it works - -Provisioning lives in a **custom image** ([`.sbx/Dockerfile`](../.sbx/Dockerfile)) that extends the official `docker/sandbox-templates:claude-code-docker` template. It bakes in everything that doesn't depend on the repo lockfile: `pnpm`, the TypeScript language server, the `playwright-cli` browser tool with a headless Chromium, and the `typescript-lsp` / `context7` / `superpowers` Claude plugins. A thin runtime script ([`scripts/sbx.sh`](../scripts/sbx.sh)) handles only what must happen live: creating the sandbox, wiring the network policy and secrets, excluding the auto-generated CLAUDE.md that `sbx` writes one level above the repo (its sandbox guidance lives in [`.sbx/base.md`](../.sbx/base.md) instead), installing the repo's dependencies, and (for the clone) commit signing. - -The `docker`-flavored template is used rather than the `minimal` one because mount mode overlays `node_modules` with a container-local copy via a privileged bind mount (`CAP_SYS_ADMIN`), and only this flavor is granted that capability — so a dependency the agent installs stays inside the sandbox and never reaches the host. - -The agent's instructions are the markdown files in [`.sbx/`](../.sbx) (`base.md` plus `mount.md` or `clone.md`), concatenated and passed via `--append-system-prompt`. - -## One-time setup - -**1. Install the `sbx` CLI** — on macOS via Homebrew (`brew install sbx`); see the [Docker Sandboxes docs](https://docs.docker.com/ai/sandboxes/) for other platforms. Docker is also required (the image is built with `docker build`). - -**2. Create a read-only GitHub token (required).** In GitHub → Settings → Developer settings → **Fine-grained tokens**, create a token with Resource owner = your account and Repository access = **"Public repositories (read-only)"** (no extra permissions needed). This lets `gh` read PRs/issues/repos inside the sandbox at the higher authenticated rate limit; writes fail server-side because the token is read-only. The token is stored via the `sbx` proxy and **never enters the sandbox** — the sandbox only sees a placeholder. - -**3. Create a dedicated SSH signing key (required).** This signs the clone's commits. It is a **signing-only** key — it grants no push or auth ability: - -```bash -ssh-keygen -t ed25519 -f ~/.ssh/sbx_signing -C "sbx signing" -``` - -Then add the **public** key (`~/.ssh/sbx_signing.pub`) to GitHub → SSH and GPG keys → New SSH key → **Key type: Signing Key** (not Authentication). Override the path with `SBX_SIGNING_KEY` if you keep it elsewhere. - -**4. Run setup:** - -```bash -./scripts/sbx.sh setup -``` - -This logs in to Docker, sets the default network policy, **builds the sandbox image**, and stores your secrets: the required GitHub PAT, an optional Anthropic key (subscription users sign in via OAuth on first mount instead), an optional `context7` key, and — if `SONAR_TOKEN` is exported on the host — a SonarCloud token. Setup fails with guidance if the PAT or signing key is missing. - -## Mount sandbox — hands-on, live files (`pnpm sbx:mount`) - -The agent edits your live working tree (changes show up in your editor immediately) and can run tests/build inside the sandbox, with no permission prompts but a constrained network. You review diffs and commit on the host. The agent's dev server stays inside the sandbox (it drives it there with `playwright-cli`); to view the app yourself, run `pnpm start` on the host against the same live files. - -> **node_modules isolation:** on every mount the script overlays `node_modules` with a container-local directory (`sudo mount --bind` of `/home/agent/nm` over the repo's `node_modules`) and runs `pnpm install` into it — so everything the agent installs stays inside the sandbox and your host `node_modules` is never touched. This is **mandatory**: if the overlay can't be established the mount **aborts** rather than falling back to the host-backed `node_modules`. The overlay also keeps tests/builds fast: reading `node_modules` (~80k tiny files) over the macOS↔Linux file share is much slower, so the native-filesystem copy avoids that. The install runs on first mount (a few minutes; reused while the lockfile is unchanged); reconnecting with `pnpm sbx:mount` re-establishes the overlay if the sandbox was restarted. Changed dependencies while the sandbox is up? `./scripts/sbx.sh refresh-deps`. - -> **Avoid `pnpm install` on the host while a mount session is live — it can wedge the sandbox.** A host install recreates `node_modules` out from under the overlay's bind mount, which poisons the container's working directory; the sandbox then fails every command with `getcwd` / missing-module errors. **Recovery:** exit the session and re-run `pnpm sbx:mount` — it detects a wedged sandbox and restarts it (add `-- --continue` to resume the conversation; no purge needed). This is inherent to overlaying `node_modules` on the shared tree — so prefer changing host dependencies when no mount session is running, then let the next mount pick them up (or use `./scripts/sbx.sh refresh-deps` from within a healthy session). - -> **Editor integration (mount only):** the sandbox mounts your live editor-lock dir (`~/.claude/ide`) **read-only** — it sees your editor's current Claude Code lock but can't disturb it, so it never interferes with your host editor. The mechanism is the editor-agnostic Claude Code IDE-lock protocol, so in principle it works with any editor that integrates with Claude Code — Neovim via [`coder/claudecode.nvim`](https://github.com/coder/claudecode.nvim), VS Code, JetBrains — though it has only been **tested with Neovim**. If your editor is running on this repo when you mount, `pnpm sbx:mount` opens a **port-scoped** path to its WebSocket and starts a forwarder; run `/ide` in the session to connect (diffs, selection, diagnostics). Only this repo's editor port is opened — not general host access. Re-run `pnpm sbx:mount` if you start/restart the editor after mounting. The whole editor-link is best-effort: every step is time-bounded and retried, so if `sbx` is unresponsive it prints a notice and the sandbox still comes up — it never blocks the mount. Clone mode has no editor integration by design (it's autonomous). - -## Clone sandbox — autonomous (`pnpm sbx:clone`) - -The agent works on a private, isolated clone: it branches, runs tests, and commits on its own. Its commits are **signed** with the dedicated SSH signing key. Your host `node_modules` is never touched — the clone runs its own `pnpm install`. - -The clone skips the pre-commit hook (`RUN_PRE_COMMIT_HOOK=0`): the per-edit format hook plus the agent's "run `pnpm test`/`pnpm lint` before finishing" instruction already cover lint/types/tests. The commit-msg hook still runs — the agent doesn't otherwise validate messages against the project's commitlint config — and pre-push never fires since the clone can't push. The agent is told to run `pnpm d2-app-scripts i18n extract` as its last step, the one thing the pre-commit hook does that nothing else covers. - -The clone can **fetch/pull from GitHub** (`pnpm sbx:clone` points `origin` at HTTPS, so the public repo needs no credentials) — e.g. `git fetch origin master` to branch off the latest master. It **cannot push** (no push credentials, by design). - -### Reviewing the clone's work - -The agent commits to a feature branch **inside** the clone — it does not push anywhere. To get its work onto your host for review: - -1. `pnpm sbx:clone` wires up a host git remote, `sandbox-event-visualizer-app-clone`, pointing at the clone's git daemon. It is re-wired on every run (the daemon's published port changes), so fetch while the sandbox is running. -2. Fetch and inspect the agent's branch: - - ```bash - git fetch sandbox-event-visualizer-app-clone - git branch -r | grep sandbox-event-visualizer-app-clone # list its branches - git log --show-signature sandbox-event-visualizer-app-clone/ - git diff master...sandbox-event-visualizer-app-clone/ - ``` - -3. Check it out locally to review or build on: - - ```bash - git checkout -b review/ sandbox-event-visualizer-app-clone/ - ``` - -4. Integrate what you want (merge, cherry-pick, or open a PR) — or just discard the branch. The remote is **fetch-only** (the sandbox serves it read-only): you pull from it, never push to it. - -Unlike the mount, the clone gets a **one-way copy** of this project's memory at create (no sessions, no settings — it stays isolated). Re-push the latest host memory with `./scripts/sbx.sh sync-clone`. - -> The clone runs `pnpm install` at create. Its `postinstall` runs `generate-types`, which fetches the OpenAPI spec from the DHIS2 dev instance (the provisioned network rule allows it), and the install pulls the Cypress binary too (its CDN is allow-listed), with the Electron/GTK system libs baked into the image so `cypress` can actually run. - -## Planning and reviewing plans - -Prefer the **superpowers** skills for planning (`superpowers:brainstorming` → `superpowers:writing-plans`) — name the skill in your prompt to trigger it reliably. Superpowers writes its spec/plan into `docs/superpowers/` **in the repo**, so the mount surfaces them in your editor live and the clone commits them for `git fetch` review. Remove those files before opening the PR unless you want to keep them. - -Native plan mode is fine for quick, scoped checks, but it saves plan files to `~/.claude/plans` **inside the VM**, which is otherwise invisible on the host. So each sandbox symlinks that dir onto a per-sandbox host directory, `~/.claude/sbx-plans//`, bind-mounted in at create — open that folder on the host to read native plan files as the agent writes them. It's keyed by sandbox name, so a mount and a clone running at once never clash. - -## Browser automation - -Both sandboxes use the **Playwright agent CLI** (`playwright-cli`), baked into the image along with a matching headless Chromium (no runtime download). The agent drives it with commands like `playwright-cli open http://localhost:3000`, `playwright-cli snapshot`, `click`, `fill`, and `screenshot`; the installed Playwright skill documents common flows. Start the dev server first, then point it at `http://localhost:3000`. There is no `chrome-devtools` MCP in the sandbox. - -## GitHub (read-only) - -`gh` works inside the sandbox for reads (`gh pr list`, `gh pr view`, `gh api …`) at the authenticated rate limit, using the read-only PAT from setup. The token is injected by the `sbx` proxy on outbound GitHub requests and never enters the sandbox — the sandbox environment only holds a placeholder. Writes (creating/merging PRs, pushing) fail server-side because the token is read-only. - -## SonarQube skill - -The `sonarqube-fix` skill (`pnpm sonar`) depends on `SONAR_TOKEN` the same way it does on the host. If you export `SONAR_TOKEN` on the host before `setup`, it's stored as a proxy-injected placeholder (never exposed inside the sandbox) so the skill works in the sandbox just as it would on your host. - -## Other commands - -```bash -./scripts/sbx.sh setup # one-time: build image, set default policy, store secrets -./scripts/sbx.sh rebuild # rebuild + reload the image after editing .sbx/ (secrets untouched) -./scripts/sbx.sh refresh-deps # reinstall the mount's container-local node_modules -./scripts/sbx.sh sync-clone # re-copy this project's memory into the clone (host -> clone) -./scripts/sbx.sh reset-clone # wipe the clone back to a clean checkout -./scripts/sbx.sh purge # remove both sandboxes -``` - -Extra Claude flags are forwarded — pass them after `--`, e.g. `pnpm sbx:mount -- --continue` or `pnpm sbx:clone -- --model opus`. - -`pnpm sbx:mount` mounts _this project's_ Claude history + memory (`~/.claude/projects/`) into the sandbox **read-write**, so `pnpm sbx:mount -- --continue` (or `--resume`) picks up your host conversation and work done in the sandbox flows back. (Only this project's dir is shared — no credentials or other projects. Don't run host Claude and the sandbox on this project simultaneously; they'd write the same files.) - -## Tooling and constraints - -The `typescript-lsp`, `context7`, and `superpowers` plugins, the `grep` MCP, the `playwright-cli` browser tool, and the prettier/eslint format hook all work inside the sandbox. Only project-level config (committed `.claude/`) is picked up — your _host_ user-level MCP servers are not propagated in. `gh` is read-only (see above), so a misbehaving session can't push or open PRs. - -Outbound network uses the `balanced` default-deny egress policy plus a shared allowlist in [`.sbx/network-allowlist.txt`](../.sbx/network-allowlist.txt) (one host/pattern per line; this repo's DHIS2 instance host is added automatically). Edit that file to grant the sandbox access to another host, then recreate the sandbox. The restriction is defense-in-depth against data exfiltration — a session can read the repo, credentials, and memory, so limiting where it can send them matters. - -Only the `.sbx/Dockerfile` is baked into the image — after changing it, run `./scripts/sbx.sh rebuild`. The instructions (`.sbx/*.md`), the allowlist, and the forwarder are read at runtime, so changes to them need no rebuild. Either way, recreate the sandbox (`./scripts/sbx.sh purge`, then mount/clone) to pick up the changes. diff --git a/package.json b/package.json index 419b1b9db..510bbaf36 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,6 @@ "cy:comp:open": "cypress open --component", "cy:comp:run": "cypress run --component --browser chrome headless", "cy:capture-fixtures": "start-server-and-test 'pnpm start' http://localhost:3000 'cypress run --e2e --browser electron --config specPattern=cypress/fixture-capture/**/*.cy.ts,video=false --env dhis2BaseUrl=https://e2e.im.dhis2.org/analytics-dev'", - "sbx:mount": "./scripts/sbx.sh mount", - "sbx:clone": "./scripts/sbx.sh clone", - "sbx:refresh-deps": "./scripts/sbx.sh refresh-deps", "sonar": "pnpm exec @sonar/scan -Dsonar.branch.name=$(git rev-parse --abbrev-ref HEAD)", "postinstall": "./scripts/postinstall.sh" }, diff --git a/scripts/sbx.sh b/scripts/sbx.sh deleted file mode 100755 index b8a94738b..000000000 --- a/scripts/sbx.sh +++ /dev/null @@ -1,644 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(git rev-parse --show-toplevel)" -PROJECT="$(basename "$REPO_ROOT")" -MOUNT_NAME="${PROJECT}-mount" -CLONE_NAME="${PROJECT}-clone" -IMAGE_TAG="${PROJECT}-sbx:latest" -SBX_DIR="$REPO_ROOT/.sbx" -PNPM_VERSION="$(node -p "require('$REPO_ROOT/package.json').packageManager.split('@')[1].split('+')[0]" 2>/dev/null || echo latest)" -# Dedicated, signing-only SSH key (see docs/claude-sandboxes.md). Not an auth/push key. -SIGNING_KEY="${SBX_SIGNING_KEY:-$HOME/.ssh/sbx_signing}" - -require_sbx() { - if ! command -v sbx >/dev/null 2>&1; then - echo "Docker Sandboxes not installed — run 'brew install sbx' to use the AI sandboxes." >&2 - exit 0 - fi -} - -require_docker() { - if ! command -v docker >/dev/null 2>&1; then - echo "Docker is required to build the sandbox image — install Docker Desktop." >&2 - exit 1 - fi -} - -sandbox_exists() { - sbx ls -q 2>/dev/null | grep -qx "$1" -} - -template_exists() { - sbx template ls 2>/dev/null | grep -q "${PROJECT}-sbx" -} - -default_branch() { - local branch - branch="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's@^origin/@@')" - echo "${branch:-master}" -} - -dhis2_host() { - node -e "try{const u=require('$REPO_ROOT/cypress.env.json').dhis2BaseUrl;process.stdout.write(u?new URL(u).host:'')}catch(e){}" 2>/dev/null -} - -# Hosts the sandbox is allowed to reach: the shared allowlist in .sbx/network-allowlist.txt -# plus this repo's DHIS2 instance host (from cypress.env.json) added at runtime. -allowed_hosts() { - local hosts dhis - hosts="$(grep -vE '^[[:space:]]*(#|$)' "$SBX_DIR/network-allowlist.txt" | paste -sd, -)" - dhis="$(dhis2_host)" - [ -n "$dhis" ] && hosts="${hosts},${dhis}" - echo "$hosts" -} - -configure_policy() { - local name="$1" - echo "Configuring network policy for '$name'..." - sbx policy allow network --sandbox "$name" "$(allowed_hosts)" >/dev/null -} - -# Accept the project trust dialog non-interactively so Claude starts without prompting. -accept_trust() { - local name="$1" - sbx exec "$name" bash -lc ' - f="/home/agent/.claude.json" - node -e " - const fs=require(\"fs\"),p=\"$1\",f=\"$f\"; - let c={};try{c=JSON.parse(fs.readFileSync(f,\"utf8\"))}catch(e){} - c.projects=c.projects||{};c.projects[p]=c.projects[p]||{}; - c.projects[p].hasTrustDialogAccepted=true; - fs.writeFileSync(f,JSON.stringify(c,null,2)); - " - ' _ "$REPO_ROOT" -} - -# `sbx create` writes a CLAUDE.md one level above the repo, fronted by an auto-detected -# per-language stub that tells the agent to use npm/yarn — this repo is pnpm-only. Its -# sandbox sections are already covered, more accurately and repo-specifically, by -# `.sbx/base.md` (injected via --append-system-prompt) and this repo's own CLAUDE.md. -# -# Exclude the file from memory loading rather than editing it: sbx owns it and rewrites it -# on every create, so any in-place edit is both fragile and undone by the next sandbox. -# `claudeMdExcludes` takes absolute paths or picomatch globs and applies to User, Project, -# and Local memory sources. Tracked upstream as docker/sbx-releases#204 and #122. -exclude_generated_memory() { - local name="$1" - sbx exec "$name" bash -lc ' - f="/home/agent/.claude/settings.json" - node -e " - const fs=require(\"fs\"),p=\"$1\",f=\"$f\"; - let c={};try{c=JSON.parse(fs.readFileSync(f,\"utf8\"))}catch(e){} - const ex=new Set(c.claudeMdExcludes||[]);ex.add(p); - c.claudeMdExcludes=[...ex].sort(); - fs.writeFileSync(f,JSON.stringify(c,null,2)); - " - ' _ "$(dirname "$REPO_ROOT")/CLAUDE.md" -} - -# sbx auto-injects its own proxy-managed GH_TOKEN placeholder (gho_sbxproxymanaged…) that -# shadows the custom read-only-PAT secret's placeholder in the sandbox environment. gh then -# receives a token with no swap rule, so every authenticated GitHub API call returns 401 -# (git-over-HTTPS still works — it uses a separate credential path). Read the configured -# placeholder from the stored secret and export it in the persistent startup so it overrides -# the built-in one; the proxy swaps it for the real read-only PAT on outbound GitHub -# requests, restoring authenticated gh reads. Idempotent — safe to re-run on every mount. -wire_github_token() { - local name="$1" placeholder - placeholder="$(sbx secret ls 2>/dev/null | grep -w GH_TOKEN | grep -oE 'ghp_[[:alnum:]]+' | head -1)" - if [ -z "$placeholder" ]; then - echo "⚠ No custom GH_TOKEN secret found — gh stays unauthenticated. Run './scripts/sbx.sh setup' to add a read-only PAT." - return 0 - fi - sbx exec "$name" bash -lc ' - sudo sed -i "\#sbx-gh-token#d" /etc/sandbox-persistent.sh 2>/dev/null || true - printf "export GH_TOKEN=%q # sbx-gh-token\n" "$1" | sudo tee -a /etc/sandbox-persistent.sh >/dev/null - ' _ "$placeholder" - echo "Wired the read-only GitHub token for '$name' (gh reads authenticated; writes blocked server-side)." -} - -# Build the custom sandbox image and load it into the sbx runtime. The sbx runtime has -# its own image store, so bridge the host docker image across via save + template load. -build_image() { - require_docker - echo "Building custom sandbox image '$IMAGE_TAG' (a few minutes)..." - docker build --build-arg "PNPM_VERSION=$PNPM_VERSION" -t "$IMAGE_TAG" -f "$SBX_DIR/Dockerfile" "$SBX_DIR" - local tar - tar="$(mktemp -t sbx-img)" - echo "Loading the image into the sandbox runtime..." - docker save "$IMAGE_TAG" -o "$tar" - sbx template load "$tar" - rm -f "$tar" -} - -ensure_image() { - if ! template_exists; then - echo "Sandbox image not loaded yet — building it now." - build_image - fi -} - -read_secret() { - local prompt="$1" var - printf '%s' "$prompt" >&2 - read -rs var || var="" - echo >&2 - printf '%s' "$var" -} - -compose_note() { - cat "$SBX_DIR/base.md" - echo - cat "$SBX_DIR/$1" -} - -maybe_inject_dhis2_creds() { - local name="$1" - [ -f "$REPO_ROOT/cypress.env.json" ] || return 0 - printf 'Inject cypress.env.json (DHIS2 test creds) into the clone for e2e? [y/N] ' - local reply - read -r reply || reply="" - case "$reply" in - [yY]*) - sbx cp "$REPO_ROOT/cypress.env.json" "${name}:${REPO_ROOT}/cypress.env.json" - echo "Copied cypress.env.json into the clone." - ;; - *) echo "Skipped DHIS2 creds." ;; - esac -} - -# Run a command, killing it and returning 124 if it exceeds . Output suppressed. -# macOS has no `timeout`, so this is a portable stand-in. Polls at 0.2s so a fast command -# returns promptly (a 1s poll added ~1s of latency to every bounded call). -run_with_timeout() { - local secs="$1"; shift - "$@" >/dev/null 2>&1 & - local pid=$! i=0 max=$((secs * 5)) - while kill -0 "$pid" 2>/dev/null; do - if [ "$i" -ge "$max" ]; then - kill -TERM "$pid" 2>/dev/null - wait "$pid" 2>/dev/null - return 124 - fi - sleep 0.2 - i=$((i + 1)) - done - wait "$pid" 2>/dev/null -} - -# Retry a command up to times, each bounded by . Returns 0 on the -# first success, 1 if all attempts fail or time out. Never hangs. -retry() { - local attempts="$1" secs="$2"; shift 2 - local n=1 - while [ "$n" -le "$attempts" ]; do - if run_with_timeout "$secs" "$@"; then return 0; fi - n=$((n + 1)) - done - return 1 -} - -# Print an editor-integration message and pause so it's readable — Claude's TUI clears -# the terminal as soon as it starts, which otherwise hides these notices. -ide_msg() { - echo "$1" - sleep 3 -} - -session_dir() { - printf '%s/.claude/projects/%s' "$HOME" "$(printf '%s' "$REPO_ROOT" | sed 's#/#-#g')" -} - -ide_dir() { - printf '%s/.claude/ide' "$HOME" -} - -# Per-sandbox host directory that backs the sandbox's native plan-mode dir. Keyed by -# sandbox name so a mount and a clone running at once write plans to separate folders. -plans_host_dir() { - printf '%s/.claude/sbx-plans/%s' "$HOME" "$1" -} - -# Symlink the RW-mounted host session dir (history + memory) into the sandbox home, -# where Claude looks for it — host and sandbox homes differ. Bounded + retried. -link_host_dirs() { - local name="$1" sdir - sdir="$(session_dir)" - [ -d "$sdir" ] || return 0 - if retry 2 12 sbx exec "$name" bash -lc 'mkdir -p "$HOME/.claude/projects"; ln -sfn "$1" "$HOME/.claude/projects/$(basename "$1")"' _ "$sdir"; then - echo "Linked session history + memory (two-way)." - else - echo "⚠ Couldn't link session history (sbx not responding) — continuing without it." - fi -} - -# Symlink the sandbox's native plan-mode dir (~/.claude/plans) onto the bind-mounted -# per-sandbox host dir, so plan files Claude writes in native plan mode are readable in the -# host editor. Bounded + retried; on failure it prints a notice and continues. The host dir -# is passed to `sbx create`, so the mount is part of the sandbox spec and survives restarts; -# the symlink lives in the persistent container FS. (Superpowers plans need none of this — -# they land in the repo tree, which the mount surfaces and the clone commits.) -link_plans_dir() { - local name="$1" pdir - pdir="$(plans_host_dir "$name")" - if retry 2 12 sbx exec "$name" bash -lc 'mkdir -p "$HOME/.claude"; rm -rf "$HOME/.claude/plans"; ln -sfn "$1" "$HOME/.claude/plans"' _ "$pdir"; then - echo "Linked native plan-mode dir to host: $pdir" - else - echo "⚠ Couldn't link the plan-mode dir (sbx not responding) — native plans will stay in the VM." - fi -} - -# Ports of live (reachable) editor locks whose workspace is this repo. Reads the -# host lock dir directly; only returns ports something is actually listening on, -# so stale locks (dead Neovim sessions) are skipped. -live_ide_ports() { - local d ports port - d="$(ide_dir)" - [ -d "$d" ] || return 0 - ports="$(node -e ' - const fs = require("fs"), path = require("path"); - const dir = process.argv[1], repo = process.argv[2], out = []; - try { - for (const f of fs.readdirSync(dir)) { - if (!f.endsWith(".lock")) continue; - let o; - try { o = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); } catch (e) { continue; } - if ((o.workspaceFolders || []).includes(repo)) out.push(path.basename(f, ".lock")); - } - } catch (e) {} - process.stdout.write(out.join(" ")); - ' "$d" "$REPO_ROOT")" - for port in $ports; do - nc -z 127.0.0.1 "$port" 2>/dev/null && echo "$port" - done - # Always succeed: this is a reachability probe, and finding nothing reachable (all - # locks stale) is normal — a non-zero exit here would abort the mount under `set -e`. - return 0 -} - -# Editor integration for /ide (mount only). Symlinks the (mounted) host lock dir into -# the sandbox home so /ide sees live locks, then for each live Neovim port for this repo -# opens a scoped network rule and starts a loopback->host forwarder. Every step is bounded -# + retried; on failure it prints a notice and continues — it never hangs or errors, so a -# flaky sbx call can't block the mount. Re-run mount if you (re)start Neovim. -ide_link() { - local name="$1" ports port fwd_b64 - ports="$(live_ide_ports)" - if [ -z "$ports" ]; then - [ -d "$(ide_dir)" ] && { retry 2 12 sbx exec "$name" bash -lc 'mkdir -p "$HOME/.claude"; rm -rf "$HOME/.claude/ide"; ln -sfn "$1" "$HOME/.claude/ide"' _ "$(ide_dir)" || true; } - ide_msg "Editor integration: no live editor for this repo — /ide will be empty (start your editor, then re-run mount)." - return 0 - fi - fwd_b64="$(base64 < "$SBX_DIR/ide-forward.js" | tr -d '\n')" - if ! retry 2 12 sbx exec "$name" bash -lc 'mkdir -p "$HOME/.claude"; rm -rf "$HOME/.claude/ide"; ln -sfn "$1" "$HOME/.claude/ide"; printf "%s" "$2" | base64 -d > /home/agent/sbx-ide-forward.js' _ "$(ide_dir)" "$fwd_b64"; then - ide_msg "⚠ Editor integration: couldn't reach the sandbox — /ide won't connect. Sandbox is otherwise fine." - return 0 - fi - for port in $ports; do - retry 2 12 sbx policy allow network --sandbox "$name" "localhost:${port},host.docker.internal" || true - # `sbx exec -d` starts the detached forwarder within ~1-2s but then blocks ~30s+ on its - # own inspect (and ignores TERM) before exiting. Fire it and DON'T wait — the orphaned - # client finishes its inspect harmlessly while the verify below confirms it's listening. - ( sbx exec -d "$name" node /home/agent/sbx-ide-forward.js "$port" >/dev/null 2>&1 & ) - if run_with_timeout 12 sbx exec "$name" bash -lc 'for _ in $(seq 1 20); do exec 3<>/dev/tcp/127.0.0.1/'"$port"' 2>/dev/null && exit 0; sleep 0.3; done; exit 1'; then - echo " Host editor linked on port $port. If Claude doesn't auto-connect, run /ide in the session to connect." - else - echo " ⚠ Editor integration: forwarder for port $port isn't listening — /ide won't connect." - fi - done - sleep 3 # keep: let the connection-outcome message stay readable before Claude's TUI clears it -} - -# Overlay node_modules with a container-local copy and install dependencies into it. This is -# both the perf path and an isolation boundary: everything the agent installs lands in -# /home/agent/nm on the sandbox's native filesystem — reading node_modules over the host file -# share is much slower for tests/builds — and never touches the host node_modules. It is -# MANDATORY — on failure the caller aborts the mount rather than falling back to the -# host-backed node_modules. The install runs only when the overlay is empty or the lockfile -# changed; on re-attach it is just a fast remount. -node_modules_overlay() { - local name="$1" - echo "Setting up container-local node_modules and installing dependencies (first run only, a few minutes)..." - if sbx exec "$name" bash -lc ' - set -e - repo="$1"; nm=/home/agent/nm - mkdir -p "$nm" "$repo/node_modules" - mountpoint -q "$repo/node_modules" || sudo mount --bind "$nm" "$repo/node_modules" - mountpoint -q "$repo/node_modules" # verify the overlay is really in place - # Strip any stale per-command remount line (older sandboxes wrote one here). The - # overlay is re-established by re-running the mount, or after a host pnpm install by - # the host postinstall calling `remount`. - sudo sed -i "\#sbx-nm-overlay#d" /etc/sandbox-persistent.sh 2>/dev/null || true - if [ ! -e "$nm/.installed" ] || [ "$repo/pnpm-lock.yaml" -nt "$nm/.installed" ]; then - cd "$repo" && pnpm install && touch "$nm/.installed" - fi - # src/locales is generated from i18n/*.po and gitignored, so a fresh sandbox has - # none and `pnpm lint` fails on the locale imports until start or build has run - # once. Generate it up front. Non-fatal: missing translations must never abort the - # mount, and the `set -e` above would otherwise make it do exactly that. No - # apostrophes in here: this whole block is a single-quoted argument to bash -lc. - if [ ! -e "$repo/src/locales/index.js" ]; then - (cd "$repo" && pnpm exec d2-app-scripts i18n generate) || true - fi - ' _ "$REPO_ROOT"; then - echo "Dependencies installed in a container-local node_modules (host node_modules untouched)." - else - echo "✗ Could not establish the container-local node_modules overlay — aborting the mount." >&2 - echo " The sandbox will NOT run against your host node_modules. Check that the sandbox is" >&2 - echo " running and that 'sudo mount --bind' is permitted on this image flavor." >&2 - return 1 - fi -} - -# One-way copy of this project's memory into a sandbox (no sessions, no settings). -# Used for the isolated clone; re-run on demand via "sync-clone". -copy_memory() { - local name="$1" projdir memsrc - projdir="$(basename "$(session_dir)")" - memsrc="$(session_dir)/memory" - [ -d "$memsrc" ] || { echo "No project memory to copy into '$name'."; return 0; } - echo "Copying project memory into '$name'..." - sbx exec "$name" bash -lc 'mkdir -p "$HOME/.claude/projects/$1"' _ "$projdir" - sbx cp "$memsrc" "${name}:/home/agent/.claude/projects/${projdir}/" - # `sbx cp` preserves the host's uid/gid (501:20 on macOS), but the sandbox runs as - # agent (1000), so the agent can read its own memories and not write them. Safe here - # because the clone's copy is private to the sandbox — never do this in mount mode, - # where the same directory is the host's own files. - sbx exec "$name" bash -lc 'sudo chown -R agent:agent "$HOME/.claude/projects/$1"' _ "$projdir" || true -} - -# Configure signed commits in the clone using the dedicated (signing-only) SSH key. -# The key is copied in because git signs locally; it grants no push/auth ability, so a -# leak only lets someone produce commits that appear authored by the key — see the docs. -setup_signing() { - local name="$1" gname gmail - gname="$(git config user.name || true)" - gmail="$(git config user.email || true)" - # sbx cp does not create parent dirs, and the image has no ~/.ssh — create it first. - sbx exec "$name" bash -lc 'mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"' - sbx cp "$SIGNING_KEY" "${name}:/home/agent/.ssh/sbx_signing" - sbx cp "${SIGNING_KEY}.pub" "${name}:/home/agent/.ssh/sbx_signing.pub" - sbx exec "$name" bash -lc ' - set -e - # sbx cp preserves the host uid, so the agent cannot read the 0600 key — take ownership. - sudo chown "$(id -un):$(id -gn)" "$HOME/.ssh/sbx_signing" "$HOME/.ssh/sbx_signing.pub" - chmod 700 "$HOME/.ssh"; chmod 600 "$HOME/.ssh/sbx_signing"; chmod 644 "$HOME/.ssh/sbx_signing.pub" - git config --global gpg.format ssh - git config --global user.signingkey "$HOME/.ssh/sbx_signing.pub" - git config --global commit.gpgsign true - [ -n "$1" ] && git config --global user.name "$1" - [ -n "$2" ] && git config --global user.email "$2" - true - ' _ "$gname" "$gmail" - echo "Signed commits configured in '$name'." -} - -cmd_mount() { - require_sbx - # pnpm forwards its "--" separator through to us; drop it so it doesn't reach Claude. - if [ "${1:-}" = "--" ]; then shift; fi - if ! sandbox_exists "$MOUNT_NAME"; then - ensure_image - echo "Creating mount sandbox '$MOUNT_NAME'..." - local extra=() - if [ -d "$(session_dir)" ]; then extra+=("$(session_dir)"); fi - # Editor-lock dir is mounted READ-ONLY: the sandbox only reads locks to discover - # Neovim; a RW mount let the sandbox's failed connect delete the host's lock. - if [ -d "$(ide_dir)" ]; then extra+=("$(ide_dir):ro"); fi - mkdir -p "$(plans_host_dir "$MOUNT_NAME")" - extra+=("$(plans_host_dir "$MOUNT_NAME")") - sbx create -t "$IMAGE_TAG" claude "$REPO_ROOT" ${extra[@]+"${extra[@]}"} --name "$MOUNT_NAME" - configure_policy "$MOUNT_NAME" - accept_trust "$MOUNT_NAME" - exclude_generated_memory "$MOUNT_NAME" - link_host_dirs "$MOUNT_NAME" - link_plans_dir "$MOUNT_NAME" - fi - # A host `pnpm install` that recreates node_modules under the overlay poisons the - # sandbox's working directory, so `sbx exec` can no longer enter it. A container restart - # clears it: if the sandbox is unresponsive, stop it here and the steps below auto-start - # it fresh. This makes re-running `pnpm sbx:mount` the one-step recovery. - if sandbox_exists "$MOUNT_NAME" && ! sbx exec "$MOUNT_NAME" true >/dev/null 2>&1; then - echo "Mount sandbox is unresponsive (a host 'pnpm install' likely disrupted it) — restarting it..." - sbx stop "$MOUNT_NAME" >/dev/null 2>&1 || true - # `sbx exec` auto-starts a stopped sandbox, but the steps below run immediately and - # node_modules_overlay aborts the mount if its exec fails. Without this wait, a slow - # restart turns the recovery path into the failure it is meant to fix. - echo "Waiting for the sandbox to come back up..." - retry 10 6 sbx exec "$MOUNT_NAME" true \ - || echo "⚠ Sandbox slow to restart — continuing; a failure below may just be that." - fi - # Editor integration is best-effort and must never block the mount. - ide_link "$MOUNT_NAME" || true - # Override sbx's built-in GH_TOKEN placeholder with the custom read-only secret's - # placeholder so authenticated gh reads work (see wire_github_token). - wire_github_token "$MOUNT_NAME" - # Mandatory isolation boundary: if the container-local node_modules overlay can't be - # established, abort rather than launch Claude against the host-backed node_modules. - node_modules_overlay "$MOUNT_NAME" || exit 1 - local note - note="$(compose_note mount.md)" - sbx run "$MOUNT_NAME" -- \ - --dangerously-skip-permissions \ - --append-system-prompt "$note" \ - "$@" -} - -cmd_clone() { - require_sbx - # pnpm forwards its "--" separator through to us; drop it so it doesn't reach Claude. - if [ "${1:-}" = "--" ]; then shift; fi - if ! sandbox_exists "$CLONE_NAME"; then - ensure_image - echo "Creating clone sandbox '$CLONE_NAME'..." - mkdir -p "$(plans_host_dir "$CLONE_NAME")" - sbx create --clone -t "$IMAGE_TAG" claude "$REPO_ROOT" "$(plans_host_dir "$CLONE_NAME")" --name "$CLONE_NAME" - # The clone inherits the host's SSH origin, which needs a key the sandbox lacks. - # Point it at HTTPS so the agent can fetch/pull the (public) repo with no credentials. - # Pushing still fails (no push creds), which is intended. - sbx exec "$CLONE_NAME" bash -lc 'cd "$1" && git remote set-url origin "$(git remote get-url origin | sed -E "s#git@github.com:#https://github.com/#")"' _ "$REPO_ROOT" || true - # Skip the pre-commit hook in the clone: the per-edit format hook and the "run - # pnpm test/lint before finishing" instruction already cover lint/types/tests. - # commit-msg still runs (lints commit messages) and pre-push never fires (the - # clone never pushes). RUN_PRE_COMMIT_HOOK=0 is read by .hooks/pre-commit. - sbx exec "$CLONE_NAME" bash -lc 'sudo sed -i "/export RUN_PRE_COMMIT_HOOK=/d" /etc/sandbox-persistent.sh; printf "export RUN_PRE_COMMIT_HOOK=0\n" | sudo tee -a /etc/sandbox-persistent.sh >/dev/null' || true - configure_policy "$CLONE_NAME" - accept_trust "$CLONE_NAME" - exclude_generated_memory "$CLONE_NAME" - link_plans_dir "$CLONE_NAME" - setup_signing "$CLONE_NAME" - echo "Installing dependencies in the clone (generate-types hits the DHIS2 instance; includes the Cypress binary)..." - sbx exec "$CLONE_NAME" bash -lc 'cd "$1" && pnpm install' _ "$REPO_ROOT" \ - || echo "⚠ Dependency install failed — the agent can retry with: pnpm install" - copy_memory "$CLONE_NAME" - maybe_inject_dhis2_creds "$CLONE_NAME" - fi - # Override sbx's built-in GH_TOKEN placeholder with the custom read-only secret's - # placeholder so authenticated gh reads work (see wire_github_token). - wire_github_token "$CLONE_NAME" - local note - note="$(compose_note clone.md)" - sbx run "$CLONE_NAME" -- \ - --dangerously-skip-permissions \ - --append-system-prompt "$note" \ - "$@" - echo - echo "Retrieve the clone's commits on the host with:" - echo " git fetch sandbox-${CLONE_NAME}" - echo " git log sandbox-${CLONE_NAME}/" -} - -cmd_sync_clone() { - require_sbx - if ! sandbox_exists "$CLONE_NAME"; then - echo "No clone sandbox '$CLONE_NAME' — run 'pnpm sbx:clone' first." >&2 - exit 1 - fi - copy_memory "$CLONE_NAME" -} - -cmd_reset_clone() { - require_sbx - if ! sandbox_exists "$CLONE_NAME"; then - echo "No clone sandbox '$CLONE_NAME' to reset." >&2 - exit 1 - fi - local branch - branch="$(default_branch)" - sbx exec "$CLONE_NAME" bash -lc "git reset --hard origin/${branch} && git clean -fdx" -} - -# Reinstall the mount's container-local node_modules from the current lockfile. -# Use after changing dependencies; a fresh mount picks up lockfile changes on its own. -cmd_refresh_deps() { - require_sbx - if ! sandbox_exists "$MOUNT_NAME"; then - echo "No mount sandbox '$MOUNT_NAME' — run 'pnpm sbx:mount' first." >&2 - exit 1 - fi - echo "Reinstalling container-local node_modules..." - sbx exec "$MOUNT_NAME" bash -lc ' - set -e - repo="$1"; nm=/home/agent/nm - mountpoint -q "$repo/node_modules" || { mkdir -p "$nm" "$repo/node_modules"; sudo mount --bind "$nm" "$repo/node_modules"; } - cd "$repo" && pnpm install && touch "$nm/.installed" - echo "Refreshed." - ' _ "$REPO_ROOT" -} - -cmd_purge() { - require_sbx - sbx rm --force "$MOUNT_NAME" "$CLONE_NAME" || true -} - -cmd_rebuild() { - require_sbx - build_image - echo "Image rebuilt. Recreate sandboxes ('pnpm sbx:purge' then mount/clone) to pick it up." -} - -# True if a stored secret already uses the given service name or custom env var. -# Lets setup be re-run safely: existing secrets are kept, only missing ones are added -# (re-adding a global custom secret with the same env would otherwise be a duplicate). -secret_exists() { - sbx secret ls 2>/dev/null | grep -qw "$1" -} - -cmd_setup() { - require_sbx - require_docker - sbx login - if ! sbx policy set-default balanced 2>/dev/null; then - echo "Network policy already set — keeping it. (To change: sbx policy reset, then re-run setup.)" - fi - - build_image - - if secret_exists anthropic; then - echo "Anthropic credential already configured — keeping it." - else - printf 'Store an Anthropic API key? Subscription users skip this and sign in via OAuth on first mount. [y/N] ' - local areply - read -r areply || areply="" - case "$areply" in - [yY]*) sbx secret set -g anthropic ;; - *) echo "Skipping — Claude will prompt for interactive OAuth login on first 'pnpm sbx:mount'." ;; - esac - fi - - # GitHub read-only token (required). Injected as a placeholder GH_TOKEN; the proxy swaps - # in the real token on outbound GitHub requests, so it never enters the sandbox. A - # read-only fine-grained PAT means gh works for reads and writes fail server-side. - if secret_exists GH_TOKEN; then - echo "GitHub token already configured — keeping it. (To replace: 'sbx secret rm -g --placeholder ' from 'sbx secret ls', then re-run setup.)" - else - local pat - pat="$(read_secret 'GitHub read-only fine-grained PAT (required; see docs/claude-sandboxes.md): ')" - if [ -z "$pat" ]; then - echo "No PAT entered. A read-only PAT is required — see docs/claude-sandboxes.md, then re-run setup." >&2 - exit 1 - fi - sbx secret set-custom -g --host api.github.com --host github.com --host codeload.github.com \ - --env GH_TOKEN --placeholder 'ghp_{rand}' --value "$pat" - echo "GitHub token stored (read-only; proxy-injected, never exposed inside the sandbox)." - fi - - # Signing key (required). Verified here; wired into each clone at creation. - if [ ! -f "$SIGNING_KEY" ] || [ ! -f "${SIGNING_KEY}.pub" ]; then - echo "Signing key not found at '$SIGNING_KEY'(.pub). Create a dedicated signing key:" >&2 - echo " ssh-keygen -t ed25519 -f '$SIGNING_KEY' -C 'sbx signing'" >&2 - echo "then add the .pub as a *Signing Key* on GitHub. See docs/claude-sandboxes.md." >&2 - exit 1 - fi - echo "Signing key found at '$SIGNING_KEY'." - - # context7 (optional): higher rate limits. Proxy-injected placeholder, never in the sandbox. - if secret_exists CONTEXT7_API_KEY; then - echo "context7 key already configured — keeping it." - else - printf 'Configure an optional context7 API key (higher rate limits)? [y/N] ' - local reply - read -r reply || reply="" - case "$reply" in - [yY]*) - local key - key="$(read_secret 'context7 API key: ')" - if [ -n "$key" ]; then - sbx secret set-custom -g --host context7.com --env CONTEXT7_API_KEY --value "$key" - echo "context7 key stored (proxy-injected; never exposed inside the sandbox)." - else - echo "No key entered — skipping." - fi - ;; - *) echo "Skipped context7 key (works keyless at a lower rate limit)." ;; - esac - fi - - # SonarCloud token (optional): public DHIS2 projects read anonymously; a token only - # raises limits. Proxy-injected as a placeholder, never exposed inside the sandbox. - if secret_exists SONAR_TOKEN; then - echo "SonarCloud token already configured — keeping it." - elif [ -n "${SONAR_TOKEN:-}" ]; then - sbx secret set-custom -g --host sonarcloud.io --host api.sonarcloud.io \ - --env SONAR_TOKEN --placeholder 'sqp_{rand}' --value "$SONAR_TOKEN" - echo "SonarCloud token stored from the host SONAR_TOKEN (proxy-injected)." - fi - - echo "Setup complete. Run 'pnpm sbx:mount' or 'pnpm sbx:clone'." -} - -case "${1:-}" in - mount) cmd_mount "${@:2}" ;; - clone) cmd_clone "${@:2}" ;; - sync-clone) cmd_sync_clone ;; - reset-clone) cmd_reset_clone ;; - refresh-deps) cmd_refresh_deps ;; - rebuild) cmd_rebuild ;; - purge) cmd_purge ;; - setup) cmd_setup ;; - *) - echo "Usage: scripts/sbx.sh {setup|mount|clone|sync-clone|reset-clone|refresh-deps|rebuild|purge}" >&2 - exit 1 - ;; -esac