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

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

Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support,
create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts
(real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
2026-11-05 23:35:09 +00:00

50 lines
2.2 KiB
TypeScript

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}$/;
/** 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 })),
port: opt(int({ minimum: 1, maximum: 65535 })),
identityFile: opt(str({ minLength: 1, maxLength: 4096 })),
shellPath: opt(str({ minLength: 1, maxLength: 4096 })),
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";
}