feat(p2): Gondolin sandbox environments
Some checks are pending
CI / build-check (push) Waiting to run
CI / test (coding-agent) (push) Waiting to run
CI / test (durable) (push) Waiting to run
CI / test (other) (push) Waiting to run
CI / mcp-conformance (push) Waiting to run

A sandbox is a new env type (`type: "gondolin"`): a long-lived Gondolin micro-VM on an SSH environment, with
listed host folders and an HTTP(S) host allowlist. The worker reaches it over SSH to the host, where p2's launcher
(plugins/envs/sandbox: native, or Docker when the host's Node/QEMU are too old) runs pi-env's daemon inside the VM,
so tools, workspace, terminals, uploads and MCP work unchanged. Names outside the allowlist are refused before
the host resolves them.

Conversations working in a sandbox cannot leave it: location tools, subagent spawn/configure, AgentLocations.move,
the terminal tool, and one-shot agents (which now inherit their caller's own location) all keep them there; only the
owner's Relocate moves a session out, after a confirmation. conversationConfinement() exposes it to plugins.

Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support,
create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts
(real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
This commit is contained in:
Mario Zechner 2026-11-05 23:35:04 +00:00
parent 6d3e342b25
commit 2a32a605a9
51 changed files with 3240 additions and 322 deletions

View file

@ -1,6 +1,6 @@
# Remote environments
`plugins/envs` manages SSH environments. Tools and workspace files live on the remote host; the worker,
`plugins/envs` manages SSH environments and sandboxes (Gondolin VMs on an SSH environment, see Sandboxes). Tools and workspace files live on the remote host; the worker,
Durable history, provider credentials and model calls stay on this device. Remote sessions never fall back
to local execution when configuration, trust or a connection is unavailable.
@ -34,6 +34,63 @@ own location still works on that machine. Open chats stay connected. Subagents s
where they are, on their machine, and keep running their tools there. Terminals already running stay on the machine
they were started on; new ones open where the viewed conversation works.
## Sandboxes
A sandbox is a Gondolin VM (earendil-works/gondolin: QEMU with KVM, host-side network policy) on an SSH environment,
its host. In the Envs sheet it is listed under its host, and the host's **Save and test** reports whether it can run
sandboxes: natively (Linux, `/dev/kvm` usable, Node >= 22, QEMU >= 7.2), through Docker (Linux, `/dev/kvm`, a usable
`docker`; e.g. Ubuntu 22.04, whose QEMU 6.2 is too old), or not, with the reason. Only the owner creates, edits,
starts, stops and deletes sandboxes, in the Envs sheet; plugins pick an existing one (env listings carry `type`).
```json
"box1": {
"type": "gondolin",
"host": "slayer",
"image": "alpine-base:latest",
"mounts": [{ "host": "~/sbx/box1", "guest": "/workspace" }, { "host": "~/datasets", "guest": "/data", "readonly": true }],
"allowHosts": ["registry.npmjs.org", "*.github.com"],
"cpus": 2,
"memoryMb": 2048
}
```
- **What the VM sees:** its own root file system (lost when the VM restarts) and the listed host folders; nothing else
of the host. In Docker mode folders must be inside the host user's home (only the home is shared with the container).
A writable folder must not contain or be inside `~/.pi` (p2's launcher and daemons run from there on the host).
`/opt/pi-env` (the daemon) and `/.p2-vfs` (Gondolin's own mount point) are reserved in the VM.
- **Network:** HTTP(S) to `allowHosts` only (Gondolin's patterns: `*` alone is any host, `*` inside a pattern any
substring); everything else is refused with 403 before the host resolves the name, so names do not leak to DNS. An
empty or missing list allows no host. Allowed names that resolve to private addresses are refused too. Other
protocols are not routed.
- **Lifecycle:** one long-lived VM per sandbox, shared by every session working in it. It starts at the first
connection (or **Start**, **Save and start**, **Test**) and runs until **Stop**, deleting the sandbox or moving it to
another host (both stop it first), the VM dying (the supervisor notices within seconds; the next connection starts
a new one) or a host reboot. Other configuration changes apply at its next start: the sheet says so while the
running VM is older, and offers **Save and restart**.
- **Connection:** the worker connects to the host over SSH as for the host itself, deploys pi-env's daemon there
(the same Linux binary runs in the VM) and p2's launcher (`plugins/envs/sandbox/`, under the host's
`~/.pi/p2/sandbox/<content hash>/`, checked by hash at every deployment; versions of other p2 installations stay
until unused for 30 days). It runs `p2-sandbox.sh start` (which may take minutes the first time: Docker image,
`npm ci` from the shipped lockfile, guest image download, boot) and then `p2-sandbox.sh daemon <key> <config>
<daemon> -- serve --token …`. The launcher picks native or Docker (image `p2-sandbox:<Dockerfile hash>` with Node and
QEMU only, container `p2-sandbox-<uid>-<Dockerfile hash>` with the home mounted at the same path and `/dev/kvm`
passed in), starts the sandbox's supervisor if needed (`p2-sandbox.mjs serve`, one process holding the VM; socket
and log under `~/.pi/p2/sandbox/run/<key>/`, `<key>` being the sandbox's name prefixed by a hash of this p2
installation's machine and data directory), copies the daemon into the VM and passes its stdin and stdout through.
Tools, the workspace, terminals, uploads and `send_file` then work as on any remote environment. Hosts must be
Linux. Connections to a sandbox are keyed by its configuration and its host's: editing either opens new ones.
- **Confinement:** a conversation whose environment is a sandbox cannot leave it. `location_envs` shows only the
sandbox; `location_dirs` and `location_move` take paths inside it; `subagent` spawn/configure place subagents only
in it; one-shot agents (`tools.agent`) inherit their caller's location (the `p2.inherited-location` document), so a
subagent's sandbox holds for the agents its scripts start. `AgentLocations.move` refuses to move a sandboxed session
out too, and `subagent` configure keeps a subagent working in a sandbox there whoever configures it. Only the
owner's Relocate moves a session out (the web UI asks first, also for a move to another sandbox). Confinement is decided per
conversation from its current location and the environment's configured `type`, never its name:
`conversationConfinement(harness.environments, id, ctx)` (`@p2/app/v1/worker`), backed by
`HarnessEnvironmentEntry.sandbox`. The `terminal` tool lists and drives only the session's terminals in the
caller's sandbox. Tools that run on the p2 machine or reach unconfined places (MCP, ports, conversations, …) are a
session tool policy's job, not the location's.
## Data and execution
`<P2_DATA_DIR>/envs.json` is independently observed by the server and workers. Invalid edits keep the last valid
@ -83,7 +140,9 @@ run commands on those hosts. Sharing is not filesystem isolation.
## Tests
From `apps/p2`: `npm run check`; `npm test`; focused:
`node --import ./src/source.ts --test test/p2/envs.test.ts`.
`node --import ./src/source.ts --test test/p2/envs.test.ts` and `test/p2/sandboxes.test.ts` (configuration,
launcher arguments, confinement; with `P2_TEST_GONDOLIN=1` also a real VM through the launcher and a session in a
sandbox over a loopback sshd, which need `/dev/kvm`, QEMU >= 7.2 or Docker, sshd and pi-env's packaged Linux daemon).
The tests use fake providers, temporary data/workspaces and server port 0. A disposable loopback sshd has its own
host key, authorized client key and remote HOME. The SSH case skips if sshd or a local pi-env daemon build is absent;