@stixxert/pi-docker-sandbox 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,42 @@ daemon are a liability. With this extension:
32
32
  redirect it.
33
33
  - `docker_verify` runs a live isolation audit (PASS/FAIL per check).
34
34
 
35
+ ## Two modes
36
+
37
+ **1. Deploy target (default).** `pi install` this package and the agent gets
38
+ the `docker_*` tools: a private sbx microVM, with its own daemon, to deploy
39
+ into. pi's own tools keep running wherever pi runs.
40
+
41
+ **2. Execution backend.** Load `sandbox/` instead and pi runs *against* the
42
+ sandbox: `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls` and your `!`
43
+ commands all execute inside the sbx microVM, while pi itself stays on the
44
+ host with its own auth, config, sessions and model keys.
45
+
46
+ ```bash
47
+ cd /path/to/project
48
+ pi -e /path/to/pi-docker-sandbox/sandbox
49
+ ```
50
+
51
+ This is pi's "route tool execution into an isolated environment" pattern —
52
+ the same shape as pi's [Gondolin
53
+ example](https://github.com/earendil-works/pi-mono), with an sbx microVM as
54
+ the target rather than a local QEMU VM. See [sandbox/README.md](sandbox/README.md).
55
+
56
+ It is also the **lighter alternative to running pi inside a sandbox**: no
57
+ template with pi baked in, no bootstrap seeding state into the sandbox, no
58
+ per-project auth/sessions/credential handling, no sandbox rebuild to pick up
59
+ a new pi version. The tools are *overridden*, not added — so the model's
60
+ tool list is byte-for-byte the built-in one and not one prompt token is
61
+ spent on the sandbox.
62
+
63
+ The sandbox it creates can itself be slim: [template/](template/) builds a
64
+ **648 MB** baseline automatically (vs ~2.2 GB for the pi-bearing template),
65
+ because pi no longer has to live inside it. See
66
+ [template/README.md](template/README.md).
67
+
68
+ Both modes can be active at once; the `docker_*` tools keep working when the
69
+ backend is loaded.
70
+
35
71
  ## Requirements
36
72
 
37
73
  - [pi](https://pi.dev) (the extension runs in the host pi process)
@@ -148,6 +184,11 @@ persistent name (e.g. a shared sandbox reused across restarts), pin
148
184
  - `DOCKER_SANDBOX_TEMPLATE=<name>` — use a pre-baked `sbx template` for
149
185
  auto-created sandboxes (avoids re-pulling common images every session;
150
186
  create with `sbx template save <name>` from a prepared sandbox).
187
+ - `DOCKER_SANDBOX_DEBUG=1` — emit the extension's lifecycle/GC diagnostics
188
+ (watchdog armed, teardown, startup sweep) to stderr. **Off by default**: the
189
+ extension runs inside the pi process, so stray console output would land on
190
+ the same terminal the TUI is drawing and corrupt the chat. Turn it on when
191
+ running `pi -p`, in a plain shell, or when diagnosing lifecycle issues.
151
192
 
152
193
  ## Ports (verified rules)
153
194
 
package/boundary.md CHANGED
@@ -14,6 +14,16 @@ for the **agent** and for the **human operator**.
14
14
  > Gondolin is used throughout this document simply as the concrete reference
15
15
  > deployment.
16
16
 
17
+ > **Two modes.** This document describes the default **deploy target** mode,
18
+ > where the sandbox exists for `docker_*` and the agent keeps using its own
19
+ > tools. The repo also ships an **execution backend** (`sandbox/`) that routes
20
+ > pi's built-in tools into the same sandbox, for which the agent's own VM drops
21
+ > out of the picture and workspace paths are used as-is (the workspace is
22
+ > direct-mounted at its host absolute path inside the sandbox, so no
23
+ > `/workspace` hop is involved). See
24
+ > [sandbox/README.md](sandbox/README.md) and
25
+ > [security.md](security.md#execution-backend-built-in-tools-routed-into-the-sandbox).
26
+
17
27
  ## Topology
18
28
 
19
29
  ```
package/index.ts CHANGED
@@ -124,9 +124,9 @@ async function teardownSandbox(reason: string): Promise<void> {
124
124
  ].join("\n");
125
125
  const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
126
126
  child.unref();
127
- console.error(`[docker-sandbox] session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
127
+ note(`session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
128
128
  } catch (e) {
129
- console.error(`[docker-sandbox] session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
129
+ note(`session ${reason}: teardown of "${name}" failed: ${(e as Error).message}`);
130
130
  }
131
131
  }
132
132
 
@@ -141,6 +141,22 @@ function findSbxCli(): string {
141
141
  return "sbx";
142
142
  }
143
143
 
144
+ /**
145
+ * Diagnostics are OFF by default on purpose: the extension runs inside the pi
146
+ * process, so a raw console write lands on the terminal the TUI is drawing and
147
+ * corrupts the chat transcript. Set DOCKER_SANDBOX_DEBUG=1 (or SBX_PI_DEBUG=1 /
148
+ * SBX_DEBUG=1) to get the lifecycle/GC lines on stderr — useful in `pi -p`, a
149
+ * plain shell or when diagnosing, harmless in the TUI because it is opt-in.
150
+ */
151
+ function debugEnabled(): boolean {
152
+ return /^(1|true|yes|on)$/i.test((env.DOCKER_SANDBOX_DEBUG ?? env.SBX_PI_DEBUG ?? env.SBX_DEBUG ?? "").trim());
153
+ }
154
+
155
+ /** Diagnostic line — silent unless debug logging is enabled (see debugEnabled). */
156
+ function note(message: string): void {
157
+ if (debugEnabled()) console.error(`[docker-sandbox] ${message}`);
158
+ }
159
+
144
160
  /**
145
161
  * Non-secret vars always forwarded to children (the sbx CLI and shells need
146
162
  * them to function; they are not credentials).
@@ -1486,9 +1502,9 @@ function spawnWatchdog(): void {
1486
1502
  try {
1487
1503
  const child = spawn("/bin/sh", ["-c", script], { detached: true, stdio: "ignore", env: scrubbedEnv() });
1488
1504
  child.unref();
1489
- console.error(`[docker-sandbox] watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
1505
+ note(`watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
1490
1506
  } catch (e) {
1491
- console.error(`[docker-sandbox] failed to arm watchdog: ${(e as Error).message}`);
1507
+ note(`failed to arm watchdog: ${(e as Error).message}`);
1492
1508
  }
1493
1509
  }
1494
1510
 
@@ -1644,6 +1660,36 @@ function textResult(text: string): AgentToolResult<undefined> {
1644
1660
  /* extension registration */
1645
1661
  /* ------------------------------------------------------------------ */
1646
1662
 
1663
+ let lifecycleArmed = false;
1664
+
1665
+ /**
1666
+ * Arm everything that must outlive a session: the detached watchdog (teardown
1667
+ * on pi exit + keepalive pokes while pi lives), the owner marker that stops GC
1668
+ * reclaiming a live sandbox, and the startup sweep of stale sandboxes.
1669
+ *
1670
+ * Idempotent: the docker_* extension and the sandbox execution backend both
1671
+ * call this from their own session_start, and loading both must not double-arm.
1672
+ *
1673
+ * Called separately from ensureSandbox() on purpose — ensureSandbox returns
1674
+ * early when the sandbox ALREADY exists (a pinned DOCKER_SANDBOX, a resumed
1675
+ * session), so arming only there would silently skip keepalive/teardown for
1676
+ * every sandbox that was not created by this process.
1677
+ */
1678
+ async function armSessionLifecycle(): Promise<void> {
1679
+ if (lifecycleArmed) return;
1680
+ lifecycleArmed = true;
1681
+ spawnWatchdog();
1682
+ writeOwnerMarker(sessionSandboxName());
1683
+ const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
1684
+ if (!Number.isFinite(raw) || raw <= 0) return;
1685
+ // Deliberately NOT awaited: the sweep is a background safety net for stale
1686
+ // sandboxes from crashed sessions, and session start must not wait on
1687
+ // `sbx ls` (and any sandboxd round trip) to get there.
1688
+ void gcSweep(raw)
1689
+ .then((summary) => note(summary))
1690
+ .catch((e) => note(`gc at startup failed: ${(e as Error).message}`));
1691
+ }
1692
+
1647
1693
  export default function (pi: ExtensionAPI) {
1648
1694
  // Lifecycle: tear down this session's sandbox when the session ends
1649
1695
  // (exit / Ctrl+C / Ctrl+D / SIGHUP / SIGTERM, /new, /resume, /fork).
@@ -1652,18 +1698,11 @@ export default function (pi: ExtensionAPI) {
1652
1698
  });
1653
1699
 
1654
1700
  // Crash safety net: sweep stale pi-sbx-* sandboxes at session start, and
1655
- // arm the watchdog + owner marker for the current sandbox name (covers
1656
- // /resume case).
1701
+ // arm the watchdog + owner marker for the current sandbox name (covers the
1702
+ // /resume case). Shared with the sandbox execution backend, which needs the
1703
+ // same lifecycle when it is loaded on its own.
1657
1704
  pi.on("session_start", async () => {
1658
- spawnWatchdog();
1659
- writeOwnerMarker(sessionSandboxName());
1660
- const raw = Number(env.DOCKER_SANDBOX_GC_HOURS ?? "24");
1661
- if (!Number.isFinite(raw) || raw <= 0) return;
1662
- try {
1663
- console.error(`[docker-sandbox] ${await gcSweep(raw)}`);
1664
- } catch (e) {
1665
- console.error(`[docker-sandbox] gc at startup failed: ${(e as Error).message}`);
1666
- }
1705
+ await armSessionLifecycle();
1667
1706
  });
1668
1707
 
1669
1708
  pi.registerTool({
@@ -1932,4 +1971,23 @@ export default function (pi: ExtensionAPI) {
1932
1971
  }
1933
1972
 
1934
1973
  // Named exports for tests (pi's loader only calls the default factory).
1935
- export { scrubbedEnv, envForwardMode, envAllowlist, envPassthrough, sessionSandboxName, mapHostPath, assertSafeArg };
1974
+ // The sbx kernel is also shared with the `sandbox/` execution-backend
1975
+ // extension (host pi + tools routed into the sandbox), so that the sandbox
1976
+ // lifecycle, env scrubbing and path confinement exist in exactly one place.
1977
+ export {
1978
+ scrubbedEnv,
1979
+ envForwardMode,
1980
+ envAllowlist,
1981
+ envPassthrough,
1982
+ sessionSandboxName,
1983
+ mapHostPath,
1984
+ assertSafeArg,
1985
+ hostRoot,
1986
+ findSbxCli,
1987
+ runSbxCli,
1988
+ sandboxExists,
1989
+ ensureSandbox,
1990
+ teardownSandbox,
1991
+ armSessionLifecycle,
1992
+ debugEnabled,
1993
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stixxert/pi-docker-sandbox",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "pi extension: a private docker sandbox (sbx microVM with its own daemon) as the agent's deploy target — the host's docker is never exposed.",
5
5
  "license": "Apache-2.0",
6
6
  "publishConfig": {
@@ -12,6 +12,9 @@
12
12
  },
13
13
  "files": [
14
14
  "index.ts",
15
+ "sandbox/",
16
+ "template/",
17
+ "test-loader.mjs",
15
18
  "README.md",
16
19
  "boundary.md",
17
20
  "security.md",
@@ -24,6 +27,9 @@
24
27
  "scripts": {
25
28
  "typecheck": "tsc --noEmit",
26
29
  "test": "node smoke-test.mjs",
30
+ "e2e": "node sandbox/e2e.mjs",
31
+ "template": "bash template/build.sh",
32
+ "template:check": "bash template/build.sh --check",
27
33
  "prepublishOnly": "npm run typecheck && npm test",
28
34
  "release": "semantic-release"
29
35
  },
@@ -0,0 +1,277 @@
1
+ # sbx execution backend
2
+
3
+ Run **pi on the host** with its built-in tools executed **inside a Docker
4
+ Sandbox** (`sbx`) — the same shape as pi's
5
+ [Gondolin example](https://github.com/earendil-works/pi-mono), except the
6
+ sandbox is an sbx microVM instead of a local QEMU VM.
7
+
8
+ ```bash
9
+ cd /path/to/project
10
+ pi -e /path/to/pi-docker-sandbox/sandbox
11
+ ```
12
+
13
+ That is the whole setup. There is no template to build, no pi to install
14
+ inside the sandbox, and no state to seed.
15
+
16
+ ## What it routes
17
+
18
+ `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls`, plus the user's `!`
19
+ commands. All of them are **overrides of the built-in tools** — same names,
20
+ same schemas, same descriptions, same prompt snippets and guidelines. Only
21
+ the execution changes.
22
+
23
+ That is deliberate, and it is the whole answer to "does this cost context?":
24
+ **overriding adds zero new tool schema.** Registering an `sbx_exec` tool
25
+ instead would add tokens to the system prompt on *every turn, forever*.
26
+
27
+ The system prompt's working-directory line is rewritten to say where commands
28
+ actually run, so the model is never guessing.
29
+
30
+ ## Why not run pi inside the sandbox?
31
+
32
+ Running pi inside an sbx sandbox is a bigger machine:
33
+
34
+ | | pi inside the sandbox | this extension |
35
+ |---|---|---|
36
+ | Template with `pi` npm-installed | required | **not needed** |
37
+ | Template with browser/pnpm baked | required for screenshots | optional, pi-free image is enough |
38
+ | `bootstrap-pi.sh` seeding into sandbox `~/.pi` | required | **not needed** — host `~/.pi` is the real one |
39
+ | Per-sandbox auth / sessions / model cache | required, per project | **none** — host config, host keys |
40
+ | `sbx secret` to get a model key in | required | **not needed** |
41
+ | `sbx create` + attach + type `pi` | required | one command from the project dir |
42
+ | Pick up a new pi version | rebuild the sandbox | just restart pi |
43
+
44
+ ## Design notes
45
+
46
+ **Paths are identical on both sides.** `ensureSandbox()` mounts the workspace
47
+ at its host absolute path, so `/tmp/x/f.ts` is the same file inside the
48
+ sandbox. No `/workspace` translation is involved (unlike the `docker_*` tools,
49
+ which map through `mapHostPath`).
50
+
51
+ **Bytes move as base64 in argv.** `sbx exec` stdin forwarding is not
52
+ guaranteed, so file contents travel as base64 arguments (`base64 -d >
53
+ "$2"`), chunked at 384 KB per call. The `read` path base64-decodes the file so
54
+ images and any non-UTF-8 content survive intact.
55
+
56
+ **Host secrets do not enter the sandbox.** pi's built-in `bash` tool builds
57
+ the command environment from the *full* host environment, so exporting it
58
+ verbatim would copy your API keys and tokens into the sandbox, where anything
59
+ running there could read them. The backend forwards **only `PI_*` session
60
+ metadata** (plus any names you opt into with `DOCKER_SANDBOX_ENV_ALLOWLIST`),
61
+ and never `DOCKER_*`/`COMPOSE_*`. Covered by a test.
62
+
63
+ **grep is reimplemented, not re-pointed.** pi's `grep` tool spawns host
64
+ `ripgrep` for match discovery regardless of custom operations — so simply
65
+ giving it sandbox operations would scan the *host* filesystem and require
66
+ `rg` on the host. The tool is replaced wholesale by a walk-and-match over
67
+ the transport, so matches come from sandbox content.
68
+
69
+ **`.gitignore` is honoured by using git itself.** `grep`/`find` enumerate
70
+ with `git ls-files --cached --others --exclude-standard`, which is exactly
71
+ "tracked plus untracked-but-not-ignored" — so build output (`dist/`,
72
+ `.next/`, coverage) and vendored trees stay out of results, matching what
73
+ the built-in tool descriptions promise. It runs with `safe.directory=*`,
74
+ because the workspace is a mount whose owner need not match the sandbox user
75
+ (git otherwise refuses with "detected dubious ownership") — a read-only
76
+ index query, so no repo-provided code is executed. When git is unavailable
77
+ or the path is not a repo, a pruned walk is used and only `.git` and
78
+ `node_modules` are skipped.
79
+
80
+ **No shell injection surface.** Paths and file bodies are passed as
81
+ *positional* arguments to `sh -c` (`"$1"`, `"$2"`), never spliced into the
82
+ script text, so a path can never be read as shell syntax or as an option.
83
+ `shQuote()` handles the environment exports.
84
+
85
+ **Listings are one round-trip.** pi's `ls` tool calls `stat()` for every
86
+ entry; over `sbx exec` that would be N+3 sandbox round-trips per listing. A
87
+ single POSIX-sh pass returns `d`/`f` + name and is memoised for the duration
88
+ of that one tool call (verified: 25 entries => ≤ 4 round-trips).
89
+
90
+ **It degrades instead of breaking.** If `sbx` is missing or the sandbox
91
+ cannot be provisioned, the tools fall back to local execution, the user is
92
+ notified, and the system prompt says so explicitly — the agent is never led
93
+ to believe it is sandboxed when it is not.
94
+
95
+ ## Trying it out (before publishing)
96
+
97
+ One command, nothing installed, no settings touched:
98
+
99
+ ```bash
100
+ bash sandbox/try.sh # auto: real sbx if usable, else a local container
101
+ bash sandbox/try.sh --sbx # force a real Docker Sandbox
102
+ bash sandbox/try.sh --docker # force a local container
103
+ bash sandbox/try.sh -- --model x/y # extra args are passed to pi
104
+ ```
105
+
106
+ It loads the extension with `pi -e` — exactly how `pix` loads gondolin — so the
107
+ blast radius is one pi process. Anything it creates (a container, or an sbx
108
+ sandbox) is removed on exit.
109
+
110
+ **The `--docker` path is not a toy.** It runs the *identical* ops layer with
111
+ only the `exec <target> --` verb changed, which is what makes the backend
112
+ testable on a machine that cannot run `sbx` at all (no KVM / nested virt).
113
+
114
+ ### Verifying it is actually routing
115
+
116
+ Both tools are overridden, so check with something whose answer differs inside
117
+ and outside:
118
+
119
+ ```bash
120
+ # bash: the container image, not your host OS
121
+ bash sandbox/try.sh --docker -- -p --tools bash "Run: cat /etc/os-release | head -1"
122
+ ```
123
+
124
+ ```bash
125
+ # file tools: a path that exists ONLY inside the sandbox
126
+ docker exec <container> sh -c 'echo hi > /opt/only-in-sandbox.txt'
127
+ bash sandbox/try.sh --docker -- -p --tools read "Read /opt/only-in-sandbox.txt"
128
+ ```
129
+
130
+ If the first reports the container's OS and the second returns the file, the
131
+ routing works. If the extension failed to load you get a missing-tool error
132
+ instead — never a silent fallback to the host (that case is reported in the
133
+ system prompt and via `/sbx`).
134
+
135
+ ### On the host, with real sbx
136
+
137
+ ```bash
138
+ brew install docker/tap/sbx && sbx login # once
139
+ cd /path/to/project
140
+ bash /path/to/pi-docker-sandbox/sandbox/try.sh
141
+ # or directly:
142
+ pi -e /path/to/pi-docker-sandbox/sandbox
143
+ ```
144
+
145
+ The extension auto-provisions the sandbox. `bash template/build.sh` first if you
146
+ want the lightweight template; `try.sh` tells you when it is missing.
147
+
148
+ ### Notes on loading paths
149
+
150
+ - `pi -e <file>` always works: `-e sandbox/index.ts`.
151
+ - `pi -e <dir>` needs a `pi` manifest, so `sandbox/package.json` exists for that
152
+ (same shape as the gondolin extension).
153
+ - **Do not symlink only `sandbox/` into `~/.pi/agent/extensions/`**: the
154
+ extension imports the shared sbx kernel from `../index.ts`, so it has to stay
155
+ inside the repository. Point `-e` at the repo instead (or install the repo as
156
+ a package and load the repo-relative subdirectory).
157
+
158
+ ## Configuration
159
+
160
+ Reuses the `docker_*` extension's sandbox settings — the sandbox is the same
161
+ kind of object, so `DOCKER_SANDBOX` (pin a name), `DOCKER_SANDBOX_CPUS`,
162
+ `DOCKER_SANDBOX_MEMORY`, `DOCKER_SANDBOX_TEMPLATE`,
163
+ `DOCKER_SANDBOX_WORKSPACE_RO`, `DOCKER_SANDBOX_KEEPALIVE` and
164
+ `DOCKER_SANDBOX_TEARDOWN` all apply. `DOCKER_SANDBOX_WORKSPACE_RO=1` is the
165
+ interesting one here: the sandbox then sees the project read-only while your
166
+ edits go through pi's own tools.
167
+
168
+ | Variable | Effect |
169
+ |---|---|
170
+ | `SBX_BACKEND=docker` + `SBX_DOCKER_CONTAINER=<id>` | route into a container instead (testing / non-sbx hosts) |
171
+ | `DOCKER_SANDBOX_KEEPALIVE` | **default `1` here** — keeps the VM running for the life of the pi process; set `0` to allow idle-stop |
172
+ | `DOCKER_SANDBOX_ENV_ALLOWLIST` | additionally export these host vars into the sandbox shell (default: `PI_*` only) |
173
+ | `DOCKER_SANDBOX` | pin the sandbox name (also disables per-project derivation) |
174
+ | `SBX_EPHEMERAL` | `1` = throwaway per-session sandbox, removed at exit |
175
+ | `SBX_PI_DEBUG` | `1` = log per-phase startup timings to stderr |
176
+ | `DOCKER_SANDBOX_TEARDOWN` | `remove` / `stop` / `none` (a per-project sandbox defaults to `none`) |
177
+
178
+ Note: `DOCKER_SANDBOX_WORKSPACE_RO=1` is **not** compatible with this backend —
179
+ the sandbox would mount the project read-only, so `write`/`edit` would fail.
180
+ The extension warns at session start if it is set.
181
+
182
+ Two variables are **exported** for sibling extensions:
183
+ `PI_SBX_SANDBOX` (sandbox name) and `PI_SBX_BACKEND` (`sbx` / `docker`).
184
+ An extension that shells out to a tool which only exists inside the sandbox
185
+ (the webdev/`webshot` toolchain, for example) can use these to detect
186
+ "host pi, but there is a sandbox" and route accordingly.
187
+
188
+ ## Lifecycle
189
+
190
+ The sandbox is **one per project, reused across runs**, and stays warm for as
191
+ long as pi is running:
192
+
193
+ - **Per-project name.** The sandbox is `pi-sbx-<project>-<hash>`, derived from
194
+ the nearest VCS root (the rule `sbxpi` uses), so running pi from a
195
+ subdirectory lands in the same sandbox. A pinned name also makes teardown
196
+ `none`, which is the point — see below.
197
+ - **Keepalive is ON by default.** sandboxd stops an idle sandbox ~2–4 min after
198
+ the last `sbx` call, which would make the first tool call after a pause pay a
199
+ multi-second VM boot. A detached watchdog pokes the VM every ~60 s while pi
200
+ runs, so it never goes cold mid-session. `DOCKER_SANDBOX_KEEPALIVE=0` opts out.
201
+ - **It is not destroyed when pi exits.** The VM is idle-stopped by sandboxd (so
202
+ it costs no memory), but the sandbox and everything in it — pulled docker
203
+ images, installed packages — survives. The next run reuses it.
204
+ - **...and pi being killed hard is still handled.** The watchdog is detached and
205
+ outlives pi, so keepalive/teardown bookkeeping never depends on a clean exit.
206
+ - **`SBX_EPHEMERAL=1`** restores the old behaviour: a throwaway
207
+ `pi-sbx-<pid>-<rand>` sandbox removed at exit. Use it for one-off experiments.
208
+ - A `docker` backend target is never managed — it is caller-supplied, and no sbx
209
+ lifecycle is armed for it.
210
+
211
+ ### Startup latency (why the name matters)
212
+
213
+ A per-process name combined with teardown `remove` means **a fresh `sbx create`
214
+ on every pi run** — 10–15 s, every time, because a brand-new VM has to be
215
+ provisioned and its image layers prepared. That is the single biggest cost in
216
+ this backend and it is entirely avoidable: with the per-project name the create
217
+ happens once per project, and every later run just attaches (sub-second, plus a
218
+ VM start if it has gone cold).
219
+
220
+ To see where the time actually goes:
221
+
222
+ ```bash
223
+ SBX_PI_DEBUG=1 pi -e /path/to/pi-docker-sandbox/sandbox
224
+
225
+ [sbx] template: 210ms
226
+ [sbx] ensure sandbox (create if missing): 13800ms ← first run: it is creating
227
+ [sbx] backend=sbx sandbox=pi-sbx-myapp-1a2b3c4d template=stock base keepalive=1 total=14100ms
228
+
229
+ # second run, same project:
230
+ [sbx] template: 190ms
231
+ [sbx] ensure sandbox (create if missing): 640ms
232
+ [sbx] backend=sbx sandbox=pi-sbx-myapp-1a2b3c4d template=stock base keepalive=1 total=830ms
233
+ ```
234
+
235
+ Resolving the sandbox also happens **in the background**: `session_start` does
236
+ not await it, so pi's prompt is usable immediately and the cost overlaps with
237
+ you reading it instead of gating it. The first tool call awaits the same
238
+ memoised promise.
239
+
240
+ `/sbx` prints the active backend, target and whether the sandbox is
241
+ project-scoped.
242
+
243
+ ## Known limitations
244
+
245
+ - **Timeouts, not cancellation, for file tools.** pi's `*Operations`
246
+ interfaces (other than `BashOperations`) do not receive an `AbortSignal`,
247
+ so a wedged `sbx exec` is bounded by a 120 s timeout rather than cancelled
248
+ by Esc. `bash` gets a real signal and is killed on abort.
249
+ - **Processes started inside the sandbox survive an abort.** Killing the
250
+ `sbx`/`docker` CLI's process group does not reach a process already running
251
+ inside the VM; a runaway dev server there lives until the sandbox is torn
252
+ down (or `SBX_BACKEND=docker`'s container is stopped).
253
+ - **Names containing newlines** are not representable in the directory
254
+ listing format (the same trade-off the tools' text output already makes).
255
+ - **`.gitignore` needs git.** Without git in the sandbox, only `.git` and
256
+ `node_modules` are skipped. sbx images ship git, so this is the exception.
257
+
258
+ ## Tests
259
+
260
+ ```bash
261
+ npm run typecheck
262
+ npm test # docker_* extension: registration, guards, confinement
263
+ npm run e2e # this backend, end-to-end against a real container
264
+ ```
265
+
266
+ `sbx` cannot run in CI or in a nested sandbox (it boots microVMs through a
267
+ host hypervisor), which is exactly why the transport is pluggable: `npm run
268
+ e2e` drives the identical ops layer against a real container via
269
+ `docker exec`, and the only difference from the product path is which binary
270
+ performs `exec <target> --`.
271
+
272
+ ## Relationship to the `docker_*` tools
273
+
274
+ They stay, unchanged. But note the overlap: once `bash` runs *inside* the
275
+ sandbox, `docker build` / `docker compose up` in a plain shell already hit the
276
+ sandbox's own daemon, so the `docker_*` deploy surface becomes optional sugar
277
+ rather than the only way to deploy.