From 2a32a605a9f18a5f0191f65358a2e764b2f69fc7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 5 Nov 2026 23:35:04 +0000 Subject: [PATCH] feat(p2): Gondolin sandbox environments 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. --- apps/p2/docs/remote-envs.md | 63 +- apps/p2/plugins/agent/tool.ts | 9 +- apps/p2/plugins/chat/web.tsx | 20 +- apps/p2/plugins/coding-tools/worker.ts | 5 +- apps/p2/plugins/envs/DirectoryPicker.tsx | 84 +- apps/p2/plugins/envs/EnvsPanel.tsx | 858 ++++++++++++++---- apps/p2/plugins/envs/config.ts | 81 +- apps/p2/plugins/envs/connection-revision.ts | 12 + apps/p2/plugins/envs/contract.ts | 27 +- apps/p2/plugins/envs/manager.ts | 245 ++++- apps/p2/plugins/envs/remote-env.ts | 94 +- apps/p2/plugins/envs/sandbox-host.ts | 172 ++++ apps/p2/plugins/envs/sandbox-schema.ts | 26 + apps/p2/plugins/envs/sandbox/Dockerfile | 8 + .../envs/sandbox/host-package-lock.json | 165 ++++ .../p2/plugins/envs/sandbox/host-package.json | 8 + apps/p2/plugins/envs/sandbox/p2-sandbox.mjs | 416 +++++++++ apps/p2/plugins/envs/sandbox/p2-sandbox.sh | 115 +++ apps/p2/plugins/envs/schema.ts | 37 +- apps/p2/plugins/envs/server.ts | 11 +- apps/p2/plugins/envs/style.css | 42 + apps/p2/plugins/envs/tui.ts | 8 +- apps/p2/plugins/envs/web.tsx | 1 + apps/p2/plugins/envs/worker.ts | 25 +- apps/p2/plugins/locations/contract.ts | 6 +- apps/p2/plugins/locations/server.ts | 13 +- apps/p2/plugins/locations/tool.ts | 31 +- apps/p2/plugins/mcp/environments.ts | 10 +- apps/p2/plugins/mcp/placement.ts | 12 +- apps/p2/plugins/mcp/transports.ts | 2 +- apps/p2/plugins/sessions/web.tsx | 8 + apps/p2/plugins/subagents/contract.worker.ts | 14 + apps/p2/plugins/subagents/tools.ts | 28 +- apps/p2/plugins/subagents/worker.ts | 1 + apps/p2/plugins/terminals/envs.ts | 5 +- apps/p2/plugins/terminals/main.ts | 26 +- apps/p2/plugins/terminals/tool.ts | 66 +- apps/p2/plugins/terminals/worker.ts | 19 +- apps/p2/src/runtime/worker.ts | 38 +- apps/p2/src/web/Drawer.tsx | 9 + apps/p2/src/web/icons.tsx | 7 + apps/p2/src/web/style.css | 10 + apps/p2/test/p2/envs.test.ts | 7 +- apps/p2/test/p2/mcp-environments.test.ts | 14 +- apps/p2/test/p2/relocate-worker.test.ts | 3 +- apps/p2/test/p2/remote-env-keys.test.ts | 8 +- apps/p2/test/p2/sandboxes.test.ts | 497 ++++++++++ apps/p2/test/p2/terminal-tool-unit.test.ts | 35 +- apps/p2/test/p2/tui-location.test.ts | 3 + apps/p2/test/p2/tui-p11.test.ts | 3 + apps/p2/test/p2/ui-sandboxes.test.ts | 155 ++++ 51 files changed, 3240 insertions(+), 322 deletions(-) create mode 100644 apps/p2/plugins/envs/connection-revision.ts create mode 100644 apps/p2/plugins/envs/sandbox-host.ts create mode 100644 apps/p2/plugins/envs/sandbox-schema.ts create mode 100644 apps/p2/plugins/envs/sandbox/Dockerfile create mode 100644 apps/p2/plugins/envs/sandbox/host-package-lock.json create mode 100644 apps/p2/plugins/envs/sandbox/host-package.json create mode 100644 apps/p2/plugins/envs/sandbox/p2-sandbox.mjs create mode 100755 apps/p2/plugins/envs/sandbox/p2-sandbox.sh create mode 100644 apps/p2/plugins/envs/style.css create mode 100644 apps/p2/test/p2/sandboxes.test.ts create mode 100644 apps/p2/test/p2/ui-sandboxes.test.ts diff --git a/apps/p2/docs/remote-envs.md b/apps/p2/docs/remote-envs.md index 216bdc6bf..97207a93d 100644 --- a/apps/p2/docs/remote-envs.md +++ b/apps/p2/docs/remote-envs.md @@ -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//`, 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 + -- serve --token …`. The launcher picks native or Docker (image `p2-sandbox:` with Node and + QEMU only, container `p2-sandbox--` 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//`, `` 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 `/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; diff --git a/apps/p2/plugins/agent/tool.ts b/apps/p2/plugins/agent/tool.ts index b5029ee21..521c0da27 100644 --- a/apps/p2/plugins/agent/tool.ts +++ b/apps/p2/plugins/agent/tool.ts @@ -18,7 +18,8 @@ import { formats } from "@earendil-works/pi-schema/formats"; import type { JsonValue } from "@p2/app/v1"; import { record, SUBAGENT_TASK_ENTRY } from "@p2/app/v1"; import { defineP2Tool, isTopLevel, originOf, type P2Tool } from "@p2/app/v1/worker"; -import { resolveChoice, Subagents } from "../subagents/contract.worker.ts"; +import { InheritedLocation, resolveChoice, Subagents } from "../subagents/contract.worker.ts"; +import { ownLocation } from "../subagents/tools.ts"; export const AGENT_TOOL = "agent"; const ATTEMPTS = 3; @@ -85,6 +86,8 @@ export function createAgentExtension(models: Models, access: () => ReadonlyMap { const run = await tx.doc(AgentRun, api.taskId); if (run.conversationId === undefined) { @@ -97,6 +100,10 @@ export function createAgentExtension(models: Models, access: () => ReadonlyMap env.name === session?.env)?.config.type === "gondolin"; + const lock = sandboxed && ( + + + + ); return (
) : (

{locationLabel(session.env, session.cwd)}

diff --git a/apps/p2/plugins/coding-tools/worker.ts b/apps/p2/plugins/coding-tools/worker.ts index d034f35f5..d731b0613 100644 --- a/apps/p2/plugins/coding-tools/worker.ts +++ b/apps/p2/plugins/coding-tools/worker.ts @@ -23,8 +23,9 @@ export default defineFacet((host) => { if (input.env?.cwd === undefined) return undefined; const { env } = await locateConversation(harness, input.conversationId, context, input.read); if (!env) return input.env.cwd; - const ssh = await environments.value.get("remote")?.host?.(env); - return `${input.env.cwd}\nTools run on the remote environment "${env}" (SSH host ${ssh}), not on the machine running p2.`; + const where = + (await environments.value.get("remote")?.describe?.(env)) ?? `the remote environment "${env}"`; + return `${input.env.cwd}\nTools run on ${where}, not on the machine running p2.`; }), ], }); diff --git a/apps/p2/plugins/envs/DirectoryPicker.tsx b/apps/p2/plugins/envs/DirectoryPicker.tsx index 869cb90ac..41304cb50 100644 --- a/apps/p2/plugins/envs/DirectoryPicker.tsx +++ b/apps/p2/plugins/envs/DirectoryPicker.tsx @@ -130,6 +130,16 @@ export function DirectoryPicker({ // biome-ignore lint/correctness/useExhaustiveDependencies: lists again when the folder or environment changes; load is recreated each render. useEffect(() => load(shown), [shown, env, service]); const moves = moving !== undefined && moving.env !== env; + const sandboxHost = (name: string | null) => { + const config = envs.find((entry) => entry.name === name)?.config; + return config?.type === "gondolin" ? config.host : undefined; + }; + const sandbox = sandboxHost(env) !== undefined; + // Leaving a sandbox is confirmed: outside every sandbox its tools run unconfined, in another one they get its + // folders and network access. + const leaves = moves && sandboxHost(moving.env) !== undefined; + const [leaving, setLeaving] = useState(); + const target = listing ? listingKey(env, listing.path) : undefined; return ( @@ -144,11 +154,26 @@ export function DirectoryPicker({ disabled={loading} > - {envs.map((entry) => ( - - ))} + {envs.map( + (entry) => + entry.config.type === "ssh" && ( + + ), + )} + {envs.some((entry) => entry.config.type === "gondolin") && ( + + {envs.map( + (entry) => + entry.config.type === "gondolin" && ( + + ), + )} + + )} )}
- {listing && ( - + {sandbox && ( +

+ Tools of this session run in the {env} VM and can't leave it. Only you can move the session out. +

)} + {listing && + (leaves && leaving === target ? ( +
+

+ Leave sandbox {moving?.env}? +

+

+ {sandbox + ? `Moving to ${locationLabel(env, listing.path)} gives this session's tools sandbox ${env}'s folders and allowed hosts instead.` + : `Moving to ${locationLabel(env, listing.path)} lets this session's tools run directly on ${env ?? "this device"}, with its full network access and files.`} +

+

Files written in the sandbox stay in its folders.

+
+ + +
+
+ ) : ( + + ))} {moves && (

Tools will run {env === null ? "on this device" : `on ${env}`}. The history stays; files are not diff --git a/apps/p2/plugins/envs/EnvsPanel.tsx b/apps/p2/plugins/envs/EnvsPanel.tsx index 10f88363e..84603b440 100644 --- a/apps/p2/plugins/envs/EnvsPanel.tsx +++ b/apps/p2/plugins/envs/EnvsPanel.tsx @@ -4,6 +4,7 @@ import { ChevronIcon, CopyButton, errorText, + LockIcon, PlusIcon, Sheet, SlotList, @@ -12,11 +13,11 @@ import { useOptimisticCall, useReadable, } from "@p2/app/v1/web"; -import { useEffect, useRef, useState } from "react"; -import { type EnvSummary, Envs, type EnvTest } from "./contract.ts"; -import { ENV_NAME, type EnvConfig } from "./schema.ts"; +import { type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { type EnvSummary, Envs, type EnvTest, type SandboxStatus } from "./contract.ts"; +import { ENV_NAME, type EnvConfig, type GondolinEnvConfig, type SandboxMount, type SshEnvConfig } from "./schema.ts"; -type Draft = { +type SshDraft = { name: string; host: string; user: string; @@ -26,7 +27,7 @@ type Draft = { loginShell: boolean; shellEnv: string; }; -const EMPTY: Draft = { +const EMPTY: SshDraft = { name: "", host: "", user: "", @@ -36,21 +37,21 @@ const EMPTY: Draft = { loginShell: false, shellEnv: "", }; -function draftOf(env: EnvSummary): Draft { +function sshDraftOf(name: string, config: SshEnvConfig): SshDraft { return { - name: env.name, - host: env.config.host, - user: env.config.user ?? "", - port: env.config.port === undefined ? "" : String(env.config.port), - identityFile: env.config.identityFile ?? "", - shellPath: env.config.shellPath ?? "", - loginShell: env.config.loginShell ?? false, - shellEnv: Object.entries(env.config.shellEnv ?? {}) + name, + host: config.host, + user: config.user ?? "", + port: config.port === undefined ? "" : String(config.port), + identityFile: config.identityFile ?? "", + shellPath: config.shellPath ?? "", + loginShell: config.loginShell ?? false, + shellEnv: Object.entries(config.shellEnv ?? {}) .map(([key, value]) => `${key}=${value}`) .join("\n"), }; } -function configOf(draft: Draft): EnvConfig { +function sshConfigOf(draft: SshDraft): EnvConfig { const shellEnv: Record = {}; for (const line of draft.shellEnv.split("\n")) { if (!line.trim()) continue; @@ -84,6 +85,75 @@ function applyEnv(envs: EnvSummary[] | undefined, guess: EnvGuess): EnvSummary[] ? envs.map((env) => (env === saved ? { ...env, config: guess.config } : env)) : [...envs, { name: guess.name, config: guess.config, sessions: 0 }]; } + +type SandboxDraft = { + name: string; + host: string; + image: string; + mounts: SandboxMount[]; + allowHosts: string; + cpus: string; + memoryMb: string; +}; +function sandboxDraftOf(name: string, config: GondolinEnvConfig): SandboxDraft { + return { + name, + host: config.host, + image: config.image ?? "", + mounts: config.mounts ?? [], + allowHosts: (config.allowHosts ?? []).join("\n"), + cpus: config.cpus === undefined ? "" : String(config.cpus), + memoryMb: config.memoryMb === undefined ? "" : String(config.memoryMb), + }; +} +function sandboxConfigOf(draft: SandboxDraft): EnvConfig { + const number = (value: string, label: string) => { + if (!/^\d+$/.test(value.trim())) throw new Error(`${label} must be a number`); + return Number(value.trim()); + }; + const allowHosts = draft.allowHosts + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return { + type: "gondolin", + host: draft.host, + ...(draft.image.trim() ? { image: draft.image.trim() } : {}), + ...(draft.mounts.length ? { mounts: draft.mounts } : {}), + ...(allowHosts.length ? { allowHosts } : {}), + ...(draft.cpus.trim() ? { cpus: number(draft.cpus, "CPUs") } : {}), + ...(draft.memoryMb.trim() ? { memoryMb: number(draft.memoryMb, "Memory") } : {}), + }; +} + +/** The edited screen: an SSH environment or a sandbox, new or saved. */ +type Editing = + | { kind: "ssh"; draft: SshDraft; existing: boolean } + | { kind: "gondolin"; draft: SandboxDraft; existing: boolean }; +function editingOf(env: EnvSummary): Editing { + return env.config.type === "ssh" + ? { kind: "ssh", draft: sshDraftOf(env.name, env.config), existing: true } + : { kind: "gondolin", draft: sandboxDraftOf(env.name, env.config), existing: true }; +} +/** Locator prefix of a new sandbox's screen, followed by its preset host (env names cannot start with `+`). */ +const NEW_SANDBOX = "+sandbox:"; + +/** A sandbox's VM as last read; `unknown` when reading it failed. */ +type VmState = SandboxStatus | { state: "unknown"; error: string }; + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; +function uptime(startedAt: number): string { + const minutes = Math.floor((Date.now() - startedAt) / 60_000); + if (minutes < 1) return "less than a minute"; + if (minutes < 60) return `${minutes} min`; + if (minutes < 48 * 60) return `${Math.floor(minutes / 60)} h`; + return `${Math.floor(minutes / 1440)} d`; +} +/** Whether an SSH env's last test found a runtime for sandboxes. */ +function hostsSandboxes(env: EnvSummary): boolean { + return env.test?.ok === true && env.test.sandboxes !== undefined && env.test.sandboxes.runtime !== "none"; +} + function TestResult({ test, busy, @@ -97,20 +167,37 @@ function TestResult({ }) { if (test.ok) return ( -

-

- Connected · {test.platform} {test.arch} · pi-env {test.version} · {test.latencyMs} ms -

-

{test.home}

-

- Files are watched {test.watchMode === "native" ? "natively" : "by polling (no native change events)"}. -

- {test.warnings.map((warning) => ( -

- ⚠ {warning} + <> +

+

+ Connected · {test.platform} {test.arch} · pi-env {test.version} · {test.latencyMs} ms

- ))} -
+

{test.home}

+

+ Files are watched {test.watchMode === "native" ? "natively" : "by polling (no native change events)"}. +

+ {test.warnings.map((warning) => ( +

+ ⚠ {warning} +

+ ))} +
+ {test.sandboxes && ( +
+

+ {test.sandboxes.runtime === "none" ? ( + Sandboxes: not available + ) : ( + <> + Sandboxes: ready · Gondolin{" "} + {test.sandboxes.runtime === "native" ? "natively" : "in Docker"} + + )} +

+ {test.sandboxes.detail &&

{test.sandboxes.detail}

} +
+ )} + ); return (
@@ -264,13 +351,382 @@ function KeySection({ ); } +const NO_AUTOCORRECT = { autoCapitalize: "off", autoCorrect: "off", spellCheck: false } as const; + +function NameField({ + value, + placeholder, + onChange, +}: { + value: string; + placeholder: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function SshFields({ + draft, + existing, + update, +}: { + draft: SshDraft; + existing: boolean; + update: (patch: Partial) => void; +}) { + return ( + <> + {!existing && update({ name })} />} + +
+ + +
+ + + +