mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
A sandbox is a new env type (`type: "gondolin"`): a long-lived Gondolin micro-VM on an SSH environment, with listed host folders and an HTTP(S) host allowlist. The worker reaches it over SSH to the host, where p2's launcher (plugins/envs/sandbox: native, or Docker when the host's Node/QEMU are too old) runs pi-env's daemon inside the VM, so tools, workspace, terminals, uploads and MCP work unchanged. Names outside the allowlist are refused before the host resolves them. Conversations working in a sandbox cannot leave it: location tools, subagent spawn/configure, AgentLocations.move, the terminal tool, and one-shot agents (which now inherit their caller's own location) all keep them there; only the owner's Relocate moves a session out, after a confirmation. conversationConfinement() exposes it to plugins. Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support, create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts (real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
import { BACKGROUND_CONTEXT } from "@p2/app/v1";
|
|
import {
|
|
ChevronIcon,
|
|
FolderIcon,
|
|
QueryCache,
|
|
Sheet,
|
|
shortPath,
|
|
useNavigation,
|
|
useObject,
|
|
useReadable,
|
|
useSheet,
|
|
} from "@p2/app/v1/web";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { type DirectoryListing, Envs } from "./contract.ts";
|
|
|
|
export function directoryPickerLayer(env: string | null, path: string) {
|
|
return { kind: "sheet" as const, name: "cwd", locator: JSON.stringify({ env, path }) };
|
|
}
|
|
function locationLabel(env: string | null, cwd: string): string {
|
|
return env ? `${env}:${cwd}` : shortPath(cwd);
|
|
}
|
|
|
|
/** Listings by environment and requested path: Back and revisits show them at once, then refresh. */
|
|
const listings = new QueryCache<DirectoryListing>();
|
|
const listingKey = (env: string | null, path: string) => `${env ?? ""}\0${path}`;
|
|
|
|
function name(path: string): string {
|
|
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
|
|
}
|
|
|
|
export function DirectoryPicker({
|
|
env: initialEnv,
|
|
moving,
|
|
value,
|
|
recent = () => [],
|
|
onChange,
|
|
onClose,
|
|
}: {
|
|
/** The environment browsed: a remote env's name, or null for this device. */
|
|
env: string | null;
|
|
/** An existing session's environment: choosing another one moves the session there. */
|
|
moving?: { env: string | null };
|
|
value: string;
|
|
recent?: (env: string | null) => string[];
|
|
onChange: (env: string | null, value: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const service = useObject(Envs, {});
|
|
const envs = useReadable(service?.envs) ?? [];
|
|
// The environments' connections start now, as in Pi Mobile: the first listing need not wait for ssh.
|
|
useEffect(() => {
|
|
void service?.warm(BACKGROUND_CONTEXT).catch(() => {});
|
|
}, [service]);
|
|
const nav = useNavigation();
|
|
const sheet = useSheet("cwd");
|
|
let directory = value;
|
|
let env = initialEnv;
|
|
if (sheet?.locator) {
|
|
try {
|
|
const locator = JSON.parse(sheet.locator) as { path?: unknown; env?: unknown };
|
|
if (typeof locator.path === "string" && (typeof locator.env === "string" || locator.env === null)) {
|
|
directory = locator.path;
|
|
env = locator.env;
|
|
}
|
|
} catch {
|
|
/* Old routes start at the session's current location. */
|
|
}
|
|
}
|
|
const [input, setInput] = useState(value);
|
|
/** The listing shown, with the environment it belongs to: another environment's folders are never shown. */
|
|
const [listing, setListing] = useState<DirectoryListing & { env: string | null }>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string>();
|
|
|
|
const shown = directory ?? value;
|
|
const request = (path: string, where: string | null) => () =>
|
|
service!.listDirectory({ path, ...(where === null ? {} : { env: where }) }, BACKGROUND_CONTEXT);
|
|
// Every folder change, and a change of environment, is a step: Back returns to the previous one. The listing is
|
|
// requested with the tap, not after the step has rendered.
|
|
const browse = (path: string, environment = env) => {
|
|
const route = nav.route;
|
|
const top = route.layers.at(-1);
|
|
if (top?.kind !== "sheet" || top.name !== "cwd") return;
|
|
listings.prefetch(listingKey(environment, path), request(path, environment));
|
|
if (path === shown && environment === env) load(path);
|
|
else
|
|
void nav.push({
|
|
...route,
|
|
layers: [...route.layers.slice(0, -1), directoryPickerLayer(environment, path)],
|
|
});
|
|
};
|
|
/** Only the latest folder's answer is shown: an earlier, slower one must not replace it. */
|
|
const latest = useRef("");
|
|
const load = (path: string) => {
|
|
const key = listingKey(env, path);
|
|
latest.current = key;
|
|
setLoading(true);
|
|
setError(undefined);
|
|
const cached = listings.get(key);
|
|
if (cached) {
|
|
setListing({ ...cached, env });
|
|
setInput(cached.path);
|
|
} else setListing((current) => (current?.env === env ? current : undefined));
|
|
if (!service) return;
|
|
void listings
|
|
.fetch(key, request(path, env))
|
|
.then((next) => {
|
|
if (latest.current !== key) return;
|
|
if (
|
|
!next ||
|
|
typeof next.path !== "string" ||
|
|
(next.parent !== null && typeof next.parent !== "string") ||
|
|
!Array.isArray(next.directories) ||
|
|
!next.directories.every((entry) => typeof entry === "string")
|
|
)
|
|
throw new Error("Directory listing was interrupted. Press Enter or select a folder to retry.");
|
|
setListing({ ...next, env });
|
|
setInput(next.path);
|
|
})
|
|
.catch((caught: Error) => {
|
|
if (latest.current !== key) return;
|
|
setListing(undefined);
|
|
setError(caught.message);
|
|
})
|
|
.finally(() => {
|
|
if (latest.current === key) setLoading(false);
|
|
});
|
|
};
|
|
|
|
// 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}>
|
|
{/* Path and the choice stay pinned while the folder list scrolls with the sheet. */}
|
|
<div className="sheet-sticky directory-head">
|
|
{envs.length > 0 && (
|
|
<select
|
|
className="input"
|
|
aria-label="Environment"
|
|
value={env ?? ""}
|
|
onChange={(event) => browse("~", event.target.value || null)}
|
|
disabled={loading}
|
|
>
|
|
<option value="">This device</option>
|
|
{envs.map(
|
|
(entry) =>
|
|
entry.config.type === "ssh" && (
|
|
<option key={entry.name} value={entry.name}>
|
|
{entry.name} (remote)
|
|
</option>
|
|
),
|
|
)}
|
|
{envs.some((entry) => entry.config.type === "gondolin") && (
|
|
<optgroup label="Sandboxes">
|
|
{envs.map(
|
|
(entry) =>
|
|
entry.config.type === "gondolin" && (
|
|
<option key={entry.name} value={entry.name}>
|
|
{entry.name} · sandbox on {entry.config.host}
|
|
</option>
|
|
),
|
|
)}
|
|
</optgroup>
|
|
)}
|
|
</select>
|
|
)}
|
|
<form
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
if (input.trim()) browse(input.trim());
|
|
}}
|
|
>
|
|
<input
|
|
className="input mono"
|
|
value={input}
|
|
onChange={(event) => setInput(event.target.value)}
|
|
aria-label="Directory"
|
|
autoCapitalize="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
disabled={loading}
|
|
/>
|
|
</form>
|
|
{sandbox && (
|
|
<p className="muted small directory-note">
|
|
Tools of this session run in the {env} VM and can't leave it. Only you can move the session out.
|
|
</p>
|
|
)}
|
|
{listing &&
|
|
(leaves && leaving === target ? (
|
|
<div className="notice env-test directory-leave" role="alert">
|
|
<p>
|
|
<strong>Leave sandbox {moving?.env}?</strong>
|
|
</p>
|
|
<p>
|
|
{sandbox
|
|
? `Moving to ${locationLabel(env, listing.path)} gives this session's tools sandbox ${env}'s folders and allowed hosts instead.`
|
|
: `Moving to ${locationLabel(env, listing.path)} lets this session's tools run directly on ${env ?? "this device"}, with its full network access and files.`}
|
|
</p>
|
|
<p className="muted small">Files written in the sandbox stay in its folders.</p>
|
|
<div className="inline-form">
|
|
<button
|
|
type="button"
|
|
className="button danger"
|
|
disabled={loading}
|
|
onClick={() => onChange(env, listing.path)}
|
|
>
|
|
Move out
|
|
</button>
|
|
<button type="button" className="button" onClick={() => setLeaving(undefined)}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="button primary block directory-use"
|
|
disabled={loading}
|
|
onClick={() => (leaves ? setLeaving(target) : onChange(env, listing.path))}
|
|
>
|
|
{moves ? "Move session to" : "Use"} {locationLabel(env, listing.path)}
|
|
</button>
|
|
))}
|
|
{moves && (
|
|
<p className="muted small directory-note">
|
|
Tools will run {env === null ? "on this device" : `on ${env}`}. The history stays; files are not
|
|
copied.
|
|
</p>
|
|
)}
|
|
</div>
|
|
{error && <p className="notice error directory-error">{error}</p>}
|
|
{loading && !listing && (
|
|
<div className="directory-loading">
|
|
<div className="spinner" />
|
|
<span>{env ? `Connecting to ${env}…` : "Loading…"}</span>
|
|
</div>
|
|
)}
|
|
{listing && (
|
|
<div className={`list ${loading ? "directory-busy" : ""}`} aria-busy={loading}>
|
|
{loading && (
|
|
<div className="directory-loading">
|
|
<div className="spinner" />
|
|
<span>Loading…</span>
|
|
</div>
|
|
)}
|
|
{listing.parent && (
|
|
<button
|
|
type="button"
|
|
className="row-button mono"
|
|
onClick={() => browse(listing.parent ?? listing.path)}
|
|
>
|
|
<span>
|
|
<FolderIcon /> ..
|
|
</span>
|
|
<ChevronIcon />
|
|
</button>
|
|
)}
|
|
{listing.directories.map((path) => (
|
|
<button type="button" key={path} className="row-button mono" onClick={() => browse(path)}>
|
|
<span>
|
|
<FolderIcon /> {name(path)}
|
|
</span>
|
|
<ChevronIcon />
|
|
</button>
|
|
))}
|
|
{!listing.directories.length && <p className="empty-note">No subdirectories.</p>}
|
|
{listing.truncated && <p className="muted small">Showing the first 1,000 directories.</p>}
|
|
</div>
|
|
)}
|
|
{recent(env).length > 0 && (
|
|
<div className="list">
|
|
<span className="label">Recent</span>
|
|
{recent(env)
|
|
.slice(0, 8)
|
|
.map((path) => (
|
|
<button type="button" key={path} className="row-button mono" onClick={() => browse(path)}>
|
|
<span>
|
|
<FolderIcon /> {locationLabel(env, path)}
|
|
</span>
|
|
<ChevronIcon />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Sheet>
|
|
);
|
|
}
|