mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
import {
|
|
Connection,
|
|
type ConnectionOptions,
|
|
deployDaemon,
|
|
detectPlatform,
|
|
packagedDaemon,
|
|
RemoteExecutionEnv,
|
|
type RemotePlatform,
|
|
type SshConnectOptions,
|
|
sshArguments,
|
|
sshConnection,
|
|
} from "@earendil-works/pi-env";
|
|
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 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 }),
|
|
...(config.port === undefined ? {} : { port: config.port }),
|
|
...(config.identityFile === undefined ? {} : { identityFile: config.identityFile }),
|
|
...(config.loginShell ? { loginShell: true } : {}),
|
|
knownHostsFile: knownHostsPath(directory),
|
|
hostKeyAlias: hostKeyAlias(name),
|
|
};
|
|
}
|
|
|
|
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;
|
|
/** The detected remote system, once the connection has started. */
|
|
remote(): RemotePlatform | undefined;
|
|
/** Another environment on the same connection, at another directory (a subagent's own cwd). */
|
|
at(cwd: string): RemoteExecutionEnv;
|
|
};
|
|
|
|
/**
|
|
* A remote environment at `cwd` that connects at its first operation: building it never needs the network, and a
|
|
* failed start (offline, untrusted host key) is the error of the operation that needed it.
|
|
*/
|
|
export function remoteSession(
|
|
directory: string,
|
|
name: string,
|
|
config: EnvConfig,
|
|
cwd: string,
|
|
options: Omit<EnvConnectOptions, "binary"> = {},
|
|
): RemoteSession {
|
|
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,
|
|
...(shellPath === undefined ? {} : { shellPath }),
|
|
...(shellEnv === undefined ? {} : { shellEnv }),
|
|
});
|
|
return { env: at(cwd), connection, remote, at };
|
|
}
|