@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 +41 -0
- package/boundary.md +10 -0
- package/index.ts +74 -16
- package/package.json +7 -1
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +469 -0
- package/sandbox/index.ts +232 -0
- package/sandbox/operations.ts +496 -0
- package/sandbox/package.json +11 -0
- package/sandbox/transport.ts +377 -0
- package/sandbox/try.sh +157 -0
- package/security.md +40 -1
- package/template/Dockerfile +34 -0
- package/template/README.md +92 -0
- package/template/build.sh +168 -0
- package/template/install.sh +77 -0
- package/test-loader.mjs +53 -0
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
|
-
|
|
127
|
+
note(`session ${reason}: ${action} sandbox "${name}" (detached, retrying)`);
|
|
128
128
|
} catch (e) {
|
|
129
|
-
|
|
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
|
-
|
|
1505
|
+
note(`watchdog armed for sandbox "${name}" (pid ${pid}, teardown=${mode}, keepalive=${keep})`);
|
|
1490
1506
|
} catch (e) {
|
|
1491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|