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

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

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

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

View file

@ -1,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 };
}