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

@ -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) => (
<option key={entry.name} value={entry.name}>
{entry.name} (remote)
</option>
))}
{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 && (
<button
type="button"
className="button primary block directory-use"
disabled={loading}
onClick={() => onChange(env, listing.path)}
>
{moves ? "Move session to" : "Use"} {locationLabel(env, listing.path)}
</button>
{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={() => (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

File diff suppressed because it is too large Load diff

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,36 +219,70 @@ 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 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();
value(await env.fileInfo(info.home, context));
const latencyMs = Math.round(performance.now() - started);
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
const watchMode = watcher.mode;
await watcher.stop(context);
return {
ok: true,
testedAt: Date.now(),
platform: remote.platform,
arch: remote.arch,
home: info.home,
version: info.version,
latencyMs,
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);
const warnings: string[] = [];
let result: EnvTest;
try {
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
try {
const info = await connection.info();
const env = new RemoteExecutionEnv({ connection, id: envId(name, config), cwd: info.home });
const started = performance.now();
value(await env.fileInfo(info.home, context));
const latencyMs = Math.round(performance.now() - started);
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
const watchMode = watcher.mode;
await watcher.stop(context);
warnings.push(...remote.warnings);
result = {
ok: true,
testedAt: Date.now(),
platform: remote.platform,
arch: remote.arch,
home: info.home,
version: info.version,
latencyMs,
watchMode,
warnings,
};
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,12 +395,16 @@ 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)))
void this.browser(name)
.then((session) => session.connection.info())
.catch(() => {});
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,61 +160,73 @@ 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(
{
key,
location,
...(command.name ? { name: command.name } : {}),
...(command.shellCommand ? { command: command.shellCommand } : {}),
},
context,
// 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,
...(command.name ? { name: command.name } : {}),
...(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));