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.
217 lines
8 KiB
TypeScript
217 lines
8 KiB
TypeScript
import { pluginFile } from "@earendil-works/chord/plugins";
|
|
import { nul, num, obj, str, union } from "@earendil-works/pi-schema";
|
|
import {
|
|
contribute,
|
|
defineFacet,
|
|
defineState,
|
|
deriveTyped,
|
|
followNative,
|
|
forbidden,
|
|
isOwner,
|
|
nativeOf,
|
|
record,
|
|
serveNative,
|
|
} from "@p2/app/v1";
|
|
import { listLocalDirectory } from "@p2/app/v1/node";
|
|
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, sandboxesOn } from "./config.ts";
|
|
import { EnvManagers, EnvService, Envs, EnvTestSchema } from "./contract.ts";
|
|
import { EnvManager } from "./manager.ts";
|
|
import { EnvStore } from "./store.ts";
|
|
|
|
/**
|
|
* The sessions using environment `name`: working there, or with a subagent whose own location is there (archived
|
|
* sessions and subagents included).
|
|
*/
|
|
export const sessionsUsing = (
|
|
name: string,
|
|
sessions: readonly Session[] | undefined,
|
|
children: Readonly<Record<string, readonly Child[]>> | undefined,
|
|
): Session[] =>
|
|
(sessions ?? []).filter(
|
|
(session) => session.env === name || (children?.[session.id] ?? []).some((child) => child.location?.env === name),
|
|
);
|
|
|
|
/** The configurations' revisions last seen (null before the first), and the workers to stop once idle. */
|
|
const Restarts = defineState("p2.env-restarts", {
|
|
schema: obj({ revisions: union([record(str()), nul()]), pending: record(num()) }),
|
|
version: 1,
|
|
initial: () => ({ revisions: null, pending: {} }),
|
|
});
|
|
|
|
/** The latest test of each environment, with the revision of the configuration it tested. */
|
|
const Tests = defineState("p2.env-tests", {
|
|
schema: obj({ tests: record(obj({ revision: str(), test: EnvTestSchema })) }),
|
|
version: 1,
|
|
initial: () => ({ tests: {} }),
|
|
});
|
|
|
|
export default defineFacet((host) => {
|
|
serveNative(host, EnvManagers);
|
|
contribute(host, "p2.env-restarts", (self) => {
|
|
const service = self.get(EnvService, {});
|
|
const catalog = self.get(Catalog, {});
|
|
const status = self.get(StatusStore, {});
|
|
const hosts = self.get(Hosts, {});
|
|
const store = self.get(ChildStore, {});
|
|
const restarts = self.state(Restarts);
|
|
const changes = deriveTyped(
|
|
self,
|
|
[service.configs, catalog.sessions, status.statuses, hosts.workers, store.children],
|
|
([configs, sessions, statuses, workers, children]) => ({ configs, sessions, statuses, workers, children }),
|
|
);
|
|
self.on(changes, async ({ value: { configs, sessions, statuses, workers, children } }, ctx) => {
|
|
const revisions = Object.fromEntries(
|
|
Object.entries(configs).map(([name, config]) => [name, envRevision(config)]),
|
|
);
|
|
const previous = restarts.value.revisions;
|
|
await restarts.change((draft) => {
|
|
draft.revisions = revisions;
|
|
if (!previous) return;
|
|
// Workers of the sessions using an edited environment (also through a subagent's own location).
|
|
const edited = Object.keys({ ...previous, ...revisions }).filter(
|
|
(name) => previous[name] !== revisions[name],
|
|
);
|
|
// 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);
|
|
for (const [session, generation] of Object.entries(restarts.value.pending)) {
|
|
const worker = workers[session];
|
|
if (!worker?.running || worker.generation !== generation || statuses[session]?.tasks === 0) {
|
|
await restarts.change((draft) => {
|
|
delete draft.pending[session];
|
|
}, ctx);
|
|
if (worker?.running && worker.generation === generation) await hosts.stop({ session }, ctx);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
host.serve(EnvService, (_params, self) => {
|
|
const server = self.get(Server, {});
|
|
const catalog = self.get(Catalog, {});
|
|
const children = self.get(ChildStore, {});
|
|
const tested = self.state(Tests);
|
|
const store = self.open(
|
|
async () => {
|
|
const opened = new EnvStore(server.info.value.dataDir);
|
|
await opened.observer.start();
|
|
return opened;
|
|
},
|
|
(opened) => opened.observer.close(),
|
|
);
|
|
const manager = self.open(
|
|
async () =>
|
|
new EnvManager(
|
|
server.info.value.dataDir,
|
|
async (name) => sessionsUsing(name, catalog.sessions.value, children.children.value).length,
|
|
pluginFile("sandbox"),
|
|
),
|
|
(opened) => opened.close(),
|
|
);
|
|
self.contribute(EnvManagers, {
|
|
get manager() {
|
|
return manager.value;
|
|
},
|
|
});
|
|
// envs.json as the store last read it validly, and why the last read failed: `reload` resolves after the store
|
|
// told this source.
|
|
const snapshot = followNative(
|
|
self,
|
|
(changed) => store.value.subscribe(changed),
|
|
() => store.value.snapshot,
|
|
);
|
|
return {
|
|
configs: deriveTyped(self, [snapshot], ([value]) => value.configs),
|
|
tests: self.derive([tested], ([value]) => value.tests),
|
|
failure: self.derive([snapshot], ([value]) => value.error),
|
|
test: async ({ name }, ctx) => {
|
|
const config = await manager.value.config(name);
|
|
const test = await manager.value.test(name);
|
|
await tested.change((draft) => {
|
|
draft.tests[name] = { revision: envRevision(config), test };
|
|
}, ctx);
|
|
return test;
|
|
},
|
|
reload: async () => {
|
|
await store.value.observer.reload();
|
|
return null;
|
|
},
|
|
};
|
|
});
|
|
|
|
host.serve(Envs, (_params, principal, self) => {
|
|
if (!isOwner(principal)) throw forbidden("environments are owner-only");
|
|
const service = self.get(EnvService, {});
|
|
const manager = nativeOf(self, EnvManagers, "The SSH manager");
|
|
const catalog = self.get(Catalog, {});
|
|
const store = self.get(ChildStore, {});
|
|
const envs = self.derive(
|
|
[service.configs, catalog.sessions, service.tests, store.children],
|
|
([configs, sessions, tests, children]) =>
|
|
Object.entries(configs)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([name, config]) => ({
|
|
name,
|
|
config,
|
|
sessions: sessionsUsing(name, sessions, children).length,
|
|
...(tests[name] && tests[name].revision === envRevision(config) ? { test: tests[name].test } : {}),
|
|
})),
|
|
);
|
|
return {
|
|
variant: "owner",
|
|
members: {
|
|
envs,
|
|
failure: service.failure,
|
|
list: () => manager().manager.list(),
|
|
warm: async () => {
|
|
await manager().manager.warm();
|
|
return null;
|
|
},
|
|
save: async ({ name, config }, ctx) => {
|
|
await manager().manager.save(name, config);
|
|
await service.reload(ctx);
|
|
return null;
|
|
},
|
|
delete: async ({ name }, ctx) => {
|
|
await manager().manager.delete(name);
|
|
await service.reload(ctx);
|
|
return null;
|
|
},
|
|
test: (args, ctx) => service.test(args, ctx),
|
|
acceptHostKey: async ({ name, lines }) => {
|
|
await manager().manager.acceptHostKey(name, lines);
|
|
return null;
|
|
},
|
|
forgetHostKey: async ({ name }) => {
|
|
await manager().manager.forgetHostKey(name);
|
|
return null;
|
|
},
|
|
// Every owner (device, associated device, Radius) manages keys, as in Pi Mobile; Envs' factory excludes guests.
|
|
generateKey: async ({ name }, ctx) => {
|
|
const publicKey = await manager().manager.generateKey(name);
|
|
await service.reload(ctx);
|
|
return { publicKey, identityFile: manager().manager.keyPath(name) };
|
|
},
|
|
uploadKey: async ({ name, key }, ctx) => {
|
|
// All owners may upload; Envs' factory excludes guests before any key method is available.
|
|
const publicKey = await manager().manager.uploadKey(name, key);
|
|
await service.reload(ctx);
|
|
return { publicKey, identityFile: manager().manager.keyPath(name) };
|
|
},
|
|
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),
|
|
},
|
|
};
|
|
});
|
|
});
|