@stixxert/pi-docker-sandbox 0.1.0 → 1.1.0
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 +82 -2
- package/boundary.md +13 -2
- package/index.ts +268 -44
- package/package.json +23 -5
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +467 -0
- package/sandbox/index.ts +229 -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 +54 -2
- 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
|
@@ -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.
|