pi-mono/apps/p2/plugins/terminals/tool.ts
Mario Zechner 2a32a605a9
Some checks are pending
CI / build-check (push) Waiting to run
CI / test (coding-agent) (push) Waiting to run
CI / test (durable) (push) Waiting to run
CI / test (other) (push) Waiting to run
CI / mcp-conformance (push) Waiting to run
feat(p2): Gondolin sandbox environments
A sandbox is a new env type (`type: "gondolin"`): a long-lived Gondolin micro-VM on an SSH environment, with
listed host folders and an HTTP(S) host allowlist. The worker reaches it over SSH to the host, where p2's launcher
(plugins/envs/sandbox: native, or Docker when the host's Node/QEMU are too old) runs pi-env's daemon inside the VM,
so tools, workspace, terminals, uploads and MCP work unchanged. Names outside the allowlist are refused before
the host resolves them.

Conversations working in a sandbox cannot leave it: location tools, subagent spawn/configure, AgentLocations.move,
the terminal tool, and one-shot agents (which now inherit their caller's own location) all keep them there; only the
owner's Relocate moves a session out, after a confirmation. conversationConfinement() exposes it to plugins.

Owner-only management in the Envs sheet: sandboxes listed under their host, host test reports sandbox support,
create/edit/start/stop/restart/test, lock icons in the top bar and sidebar. Tests in test/p2/sandboxes.test.ts
(real VM cases with P2_TEST_GONDOLIN=1) and ui-sandboxes.test.ts; docs in docs/remote-envs.md.
2026-11-05 23:35:09 +00:00

336 lines
14 KiB
TypeScript

// The agent's `terminal` tool (worker): tmux commands as argv on the session's terminals, the ones the owner sees in
// the terminal panel. Code mode scripts call it (`tools.terminal({ command, args })`); without code mode `tool_search`
// loads it, as MCP tools. Replay-safe: the host does a call with the tool call's key once, and reads start where the
// first attempt's did (memoized), so a call a restart interrupted runs again without typing or opening twice.
import {
defineExtension,
defineTool,
type Extension,
type ToolExecutionApi,
type ToolExecutionResult,
} from "@earendil-works/pi-durable";
import { arr, bool, int, obj, opt, type Static, str, union } from "@earendil-works/pi-schema";
import { type Context, isRooted, joinPath, type Location, literal } from "@p2/app/v1";
import {
COMMANDS,
CommandError,
parseCommand,
type TerminalCommand,
type WaitFor,
type WaitOptions,
} from "./commands.ts";
import type { TerminalInfoSchema, TerminalReadSchema, TerminalWaitSchema } from "./contract.ts";
type Info = Static<typeof TerminalInfoSchema>;
type Read = Static<typeof TerminalReadSchema>;
type Wait = Static<typeof TerminalWaitSchema>;
/** The server's terminal calls for this session (`AgentTerminals`), each cancelled with `context`. */
export type TerminalBackend = {
list(context: Context): Promise<{ terminal: Info; foreground?: string }[]>;
open(args: { key: string; location: Location; name?: string; command?: string }, context: Context): Promise<Info>;
send(args: { key: string; target: string; keys: string[]; literal: boolean }, context: Context): Promise<Info>;
cursor(target: string, context: Context): Promise<{ terminal: Info; offset: number }>;
read(args: { target: string; from?: number; wait?: Wait }, context: Context): Promise<Read>;
capture(
args: { target: string; scrollback: number; escapes: boolean },
context: Context,
): Promise<{ terminal: Info; text: string }>;
close(args: { key: string; target: string }, context: Context): Promise<{ terminalId: string }>;
};
/**
* How the model reaches the tool, as MCP tools: from code mode scripts (`searchTools()` finds it), or, without code
* mode, by loading it with `tool_search`.
*/
export type TerminalReach = "codemode" | "tool_search";
export const TERMINAL_EXTENSION = "p2-terminals";
/** Characters of output a call returns at most: the last ones. */
const OUTPUT_CHARS = 16 * 1024;
/** Timeouts of waits by default, in seconds: send-keys returns sooner, its program usually answers at once. */
const DEFAULT_TIMEOUT_S = 10;
const SEND_TIMEOUT_S = 5;
const DESCRIPTION = `Use the session's terminals, the ones the user sees in p2's terminal panel, like tmux. Only when the user asks for a terminal, or to drive an interactive program in one; never instead of bash.
Call tools.terminal({ command, args }), args being tmux's argv after the command name (no quoting). Windows are numbered per session (1, 2, ...); -t takes a number or a name.
- new-window [-n name] [-c dir] [-w W] [-T s] [command ...]: a shell at your cwd (-c: relative to it); command is typed into it, then Enter.
- list-windows
- send-keys -t T [-l] [-w W] [-T s] key ...: tmux key names (Enter, C-c, Up, Escape, Tab, BSpace, M-x, F1) are keys, other args text; -l: all text.
- read -t T [-w W] [-T s]: output since your last read.
- capture-pane -t T [-S -N] [-e]: the screen, plus N scrollback lines; -e keeps colors.
- kill-window -t T
new-window, send-keys and read return the output since your last read of that window, after waiting: W is quiet (0.5 s without output; the default of new-window and send-keys), exit, /regex/ or none (read's default); -T is the timeout in seconds (default 10, send-keys 5, max 120). A timeout is no error.
Result: { window, output, running, foreground?, exitCode?, screen?, skipped?, timedOut? }; output is plain text, or a full-screen program's screen (screen: true); list-windows: { windows }.
const { window } = await tools.terminal({ command: "new-window", args: ["-n", "dev", "npm run dev"] });
await tools.terminal({ command: "send-keys", args: ["-t", String(window), "C-c"] });`;
const WindowSchema = obj({
window: int(),
name: opt(str()),
title: opt(str()),
location: str(),
opener: union([literal("user"), literal("agent")]),
running: bool(),
foreground: opt(str()),
exitCode: opt(int()),
error: opt(str()),
});
/** What scripts get: one shape for every command, its fields as they apply. */
const ResultSchema = obj({
window: opt(int()),
output: opt(str()),
running: opt(bool()),
foreground: opt(str()),
exitCode: opt(int()),
error: opt(str()),
screen: opt(bool()),
skipped: opt(int()),
timedOut: opt(bool()),
killed: opt(bool()),
windows: opt(arr(WindowSchema)),
});
type Result = Static<typeof ResultSchema>;
/** 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,
caller: TerminalCaller,
): Extension {
return defineExtension({
name: TERMINAL_EXTENSION,
tools: [
defineTool({
name: "terminal",
description: DESCRIPTION,
parameters: obj({
command: union(COMMANDS.map((name) => literal(name))),
args: opt(arr(str(), { description: "tmux's arguments after the command name, one per element." })),
}),
structuredOutputSchema: ResultSchema,
// Not offered to the model; only scripts call it while code mode is on.
deferred: true,
...(reach === "codemode" ? { callers: ["tools" as const] } : {}),
outputLimits: { maxBytes: OUTPUT_CHARS + 1024, retain: "tail" },
replay: "safe",
execute: async (args, api, context) => {
let command: TerminalCommand;
try {
command = parseCommand(args.command, args.args ?? []);
} catch (error) {
if (error instanceof CommandError) return failure(error.message);
throw error;
}
try {
const { result, terminalId } = await run(command, backend, await caller(api, context), api, context);
return {
output: [{ type: "text", text: describe(result) }],
structuredOutput: result,
// The card's link to the window.
details: {
command: args.command,
args: args.args ?? [],
...(result.window === undefined ? {} : { window: result.window }),
...(terminalId === undefined ? {} : { terminalId }),
},
};
} catch (error) {
if (context.abortSignal?.aborted) throw error;
return failure(error instanceof Error ? error.message : String(error));
}
},
}),
],
});
}
function failure(text: string): ToolExecutionResult {
return { output: [{ type: "text", text }], isError: true };
}
async function run(
command: TerminalCommand,
backend: TerminalBackend,
{ 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 directory = command.directory;
const location =
directory === undefined
? here
: { env: here.env, cwd: isRooted(directory) ? directory : joinPath(here.cwd, directory) };
// A rerun gets the window its first run opened, which may be outside a sandbox entered since.
const opened = reachable(
await backend.open(
{
key,
location,
...(command.name ? { name: command.name } : {}),
...(command.shellCommand ? { command: command.shellCommand } : {}),
},
context,
),
);
const wait = waitOf(command, { kind: "quiet" }, DEFAULT_TIMEOUT_S);
return read({ target: opened.id, from: 0, ...(wait ? { wait } : {}) });
}
case "list-windows": {
const windows = (await backend.list(context)).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, 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, 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 { 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; 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, env: target.env }, context);
await backend.close({ key, target: target.id }, context);
return { result: { window, killed: true } };
}
}
}
/** Where this call's read starts: the window's read cursor when the call first ran (memoized for a rerun). */
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; env: string | null }>("start", context);
if (known) return reachable(known);
const { terminal, offset } = await backend.cursor(target, 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`. */
function waitOf(options: WaitOptions, fallback: WaitFor, timeout: number): Wait | undefined {
const wait = options.wait ?? fallback;
const timeoutMs = Math.round((options.timeout ?? timeout) * 1000);
switch (wait.kind) {
case "none":
return undefined;
case "quiet":
case "exit":
return { until: wait.kind, timeoutMs };
case "match":
return { until: "match", pattern: wait.pattern, ...(wait.ignoreCase ? { ignoreCase: true } : {}), timeoutMs };
}
}
function readResult(read: Read): Result {
const screen = read.screen !== undefined;
return {
...stateOf(read.terminal, read.foreground),
...cut(read.screen ?? read.text, screen ? 0 : read.skipped + read.truncated),
...(screen ? { screen: true } : {}),
...(read.waited === "timeout" ? { timedOut: true } : {}),
};
}
/** The output's last `OUTPUT_CHARS`, with what was left out before it counted in `skipped`. */
function cut(text: string, skipped: number): { output: string; skipped?: number } {
const over = Math.max(0, text.length - OUTPUT_CHARS);
const total = skipped + over;
return { output: over ? text.slice(over) : text, ...(total ? { skipped: total } : {}) };
}
function stateOf(terminal: Info, foreground?: string): Result {
return {
window: terminal.index,
running: terminal.exited === undefined,
...(foreground ? { foreground } : {}),
...(terminal.exited?.code === undefined ? {} : { exitCode: terminal.exited.code }),
...(terminal.exited?.error === undefined ? {} : { error: terminal.exited.error }),
};
}
function windowOf(terminal: Info, foreground: string | undefined): Static<typeof WindowSchema> {
return {
window: terminal.index,
...(terminal.name ? { name: terminal.name } : {}),
...(terminal.title ? { title: terminal.title } : {}),
location: terminal.env ? `${terminal.env}:${terminal.cwd}` : terminal.cwd,
opener: terminal.agent ? "agent" : "user",
...stateOf(terminal, foreground),
running: terminal.exited === undefined,
};
}
/** The result as the transcript and the model (without code mode) read it. */
function describe(result: Result): string {
if (result.windows)
return result.windows.length === 0
? "No windows."
: result.windows
.map(
(window) =>
`${window.window}: ${[
window.name ?? window.title ?? "",
window.location,
`opened by ${window.opener}`,
status(window),
]
.filter(Boolean)
.join(" · ")}`,
)
.join("\n");
if (result.killed) return `Killed window ${result.window}.`;
const lines = [`window ${result.window} · ${status(result)}${result.screen ? " · full-screen program" : ""}`];
if (result.skipped) lines.push(`… ${result.skipped} characters skipped`);
if (result.output) lines.push(result.output);
if (result.timedOut && result.running)
lines.push(`(timed out: still running${result.foreground ? ` (foreground: ${result.foreground})` : ""})`);
return lines.join("\n");
}
function status(result: Pick<Result, "running" | "foreground" | "exitCode" | "error">): string {
if (result.running) return result.foreground ? `running ${result.foreground}` : "at the prompt";
if (result.error) return `failed: ${result.error}`;
return `exited${result.exitCode === undefined ? "" : ` with code ${result.exitCode}`}`;
}