pi-mono/apps/p2/plugins/locations/server.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

66 lines
2.7 KiB
TypeScript

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 { isSandbox } from "../envs/schema.ts";
import { Catalog, SessionMoves } from "../sessions/contract.ts";
import { AgentLocations, type EnvInfo } from "./contract.ts";
export default defineFacet((host) => {
// Only a current worker generation, for its own session; the worker checked that the owner started the turn.
host.serve(AgentLocations, (_params, principal, self) => {
if (!isWorker(principal)) throw forbidden("location tools are for session workers");
const { session, generation } = principal;
const hosts = self.get(Hosts, {});
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");
};
return {
variant: "worker",
members: {
envs: async () => {
current();
const tests = envs.tests.value;
return {
envs: Object.entries(envs.configs.value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, config]): EnvInfo => {
const last = tests[name]?.revision === envRevision(config) ? tests[name]?.test : undefined;
return {
name,
type: config.type,
host: config.host,
...(config.type !== "ssh" || config.user === undefined ? {} : { user: config.user }),
...(config.type !== "ssh" || config.port === undefined ? {} : { port: config.port }),
...(last === undefined
? {}
: {
tested: last.ok
? { ok: true, platform: last.platform, arch: last.arch }
: { ok: false, error: last.error },
}),
};
}),
};
},
directories: async ({ env, path }) => {
current();
return env ? managers().manager.listDirectory(env, path) : listLocalDirectory(path);
},
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);
},
},
};
});
});