From f083f35ddb467fa5ed7077244525913673bc7aa0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 01:25:20 -0700 Subject: [PATCH] feat(gateway): embedded operator terminal (PTY) with fail-closed isolation and kill switch --- src/config/schema.help.ts | 6 + src/config/schema.labels.ts | 3 + src/config/types.gateway.ts | 21 ++ src/config/zod-schema.ts | 7 + src/gateway/config-reload-plan.ts | 5 + src/gateway/config-reload.test.ts | 9 + src/gateway/control-ui-contract.ts | 6 + src/gateway/control-ui-csp.test.ts | 24 ++ src/gateway/control-ui-csp.ts | 29 +- src/gateway/control-ui.ts | 37 ++- src/gateway/methods/core-descriptors.ts | 6 + src/gateway/server-broadcast.ts | 4 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 8 + src/gateway/server-methods/shared-types.ts | 4 + src/gateway/server-methods/terminal.test.ts | 61 ++++ src/gateway/server-methods/terminal.ts | 175 +++++++++++ src/gateway/server-request-context.ts | 2 + src/gateway/server.impl.ts | 9 + src/gateway/server/ws-connection.ts | 3 + src/gateway/terminal/launch.test.ts | 108 +++++++ src/gateway/terminal/launch.ts | 126 ++++++++ src/gateway/terminal/pty.test.ts | 41 +++ src/gateway/terminal/pty.ts | 106 +++++++ src/gateway/terminal/session-manager.test.ts | 242 +++++++++++++++ src/gateway/terminal/session-manager.ts | 307 +++++++++++++++++++ 26 files changed, 1336 insertions(+), 15 deletions(-) create mode 100644 src/gateway/server-methods/terminal.test.ts create mode 100644 src/gateway/server-methods/terminal.ts create mode 100644 src/gateway/terminal/launch.test.ts create mode 100644 src/gateway/terminal/launch.ts create mode 100644 src/gateway/terminal/pty.test.ts create mode 100644 src/gateway/terminal/pty.ts create mode 100644 src/gateway/terminal/session-manager.test.ts create mode 100644 src/gateway/terminal/session-manager.ts diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4ac5a2129958..786f2ff74933 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -92,6 +92,12 @@ export const FIELD_HELP: Record = { "Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.", "gateway.controlUi.enabled": "Enables serving the gateway Control UI from the gateway HTTP process when true. Keep enabled for local administration, and disable when an external control surface replaces it.", + "gateway.terminal": + "Operator terminal served to Control UI and mobile clients: a PTY-backed shell on the gateway host, restricted to admin-scope operator sessions. It starts in the target agent's workspace and is refused for fully-sandboxed agents (sandbox.mode 'all') rather than handing back an unconfined host shell.", + "gateway.terminal.enabled": + "Enables the operator terminal for admin-scope clients when true (default). Disable to remove the browser/mobile shell surface entirely; an authenticated admin operator can already drive host commands, so treat this as a convenience-versus-exposure trade-off. Changing this restarts the gateway so connected clients reload with the correct terminal availability and content-security policy.", + "gateway.terminal.shell": + "Shell executable the operator terminal launches. Leave unset to use the host login shell ($SHELL on Unix, %ComSpec% on Windows), or pin an explicit interpreter for a consistent operator environment.", "gateway.auth": "Authentication policy for gateway HTTP/WebSocket access including mode, credentials, trusted-proxy behavior, and rate limiting. Keep auth enabled for every non-loopback deployment.", "gateway.auth.mode": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 3f1fda5e0e62..e80ff8b7af1b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -122,6 +122,9 @@ export const FIELD_LABELS: Record = { "gateway.customBindHost": "Gateway Custom Bind Host", "gateway.controlUi": "Control UI", "gateway.controlUi.enabled": "Control UI Enabled", + "gateway.terminal": "Operator Terminal", + "gateway.terminal.enabled": "Operator Terminal Enabled", + "gateway.terminal.shell": "Operator Terminal Shell", "gateway.auth": "Gateway Auth", "gateway.auth.mode": "Gateway Auth Mode", "gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity", diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 036c268f663b..9708162124fb 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -258,6 +258,26 @@ export type GatewayRemoteConfig = { sshHostKeyPolicy?: "strict" | "openssh"; }; +/** + * Operator terminal surface served to Control UI and mobile clients. + * + * The terminal opens a PTY-backed shell on the gateway host, gated to + * admin-scope operator sessions. It starts in the target agent's workspace; if + * that agent is fully sandboxed (`sandbox.mode: "all"`) the terminal is refused + * rather than handed an unconfined host shell (workspace isolation is + * fail-closed). Under "non-main" the agent's main session runs on the host, so a + * host terminal is allowed. + */ +export type GatewayTerminalConfig = { + /** Master switch for the operator terminal. Default: true. */ + enabled?: boolean; + /** + * Shell executable to launch. When unset the host login shell is used + * ($SHELL on Unix, %ComSpec% on Windows). + */ + shell?: string; +}; + /** Gateway config reload strategy for managed installs. */ export type GatewayReloadMode = "off" | "restart" | "hot" | "hybrid"; @@ -485,6 +505,7 @@ export type GatewayConfig = { /** Custom IPv4 address for bind="custom" mode. IPv6-only BYOH requires an IPv4 sidecar or proxy. */ customBindHost?: string; controlUi?: GatewayControlUiConfig; + terminal?: GatewayTerminalConfig; auth?: GatewayAuthConfig; tailscale?: GatewayTailscaleConfig; remote?: GatewayRemoteConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a6114a1ff0c7..77458474311c 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -1087,6 +1087,13 @@ export const OpenClawSchema = z }) .strict() .optional(), + terminal: z + .object({ + enabled: z.boolean().optional(), + shell: z.string().optional(), + }) + .strict() + .optional(), auth: z .object({ mode: z diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index ea21b8a8bef2..4ec0673bdd3e 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -56,6 +56,11 @@ const PLUGIN_INSTALL_TIMESTAMP_KEYS = ["installedAt", "resolvedAt"] as const; const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, + // gateway.terminal.* deliberately has no rule here: it falls through to the + // `gateway` restart rule below. The terminal drives the Control UI CSP (WASM + // permissions) and the bootstrap availability flag, both fixed at document + // load, plus live PTYs — none can hot-update a connected client, so a change + // must restart the gateway (clients reconnect with a fresh page and CSP). { prefix: "gateway.channelHealthCheckMinutes", kind: "hot", diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 822b9475fc45..2eb12003389e 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -198,6 +198,15 @@ describe("buildGatewayReloadPlan", () => { expect(plan.restartReasons).toContain("gateway.port"); }); + it("restarts the gateway for operator terminal config changes", () => { + // The terminal drives the Control UI CSP + bootstrap (both document-load + // time) and live PTYs, none of which hot-update a connected client, so a + // change restarts the gateway (clients reconnect with a fresh page/CSP). + const plan = buildGatewayReloadPlan(["gateway.terminal.enabled", "gateway.terminal.shell"]); + expect(plan.restartGateway).toBe(true); + expect(plan.restartReasons).toContain("gateway.terminal.enabled"); + }); + it("restarts the gateway for browser plugin config changes", () => { const plan = buildGatewayReloadPlan(["browser.enabled"]); expect(plan.restartGateway).toBe(true); diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 173be60eae86..4e5a0cd62e10 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -23,4 +23,10 @@ export type ControlUiBootstrapConfig = { seamColor?: string; /** Resolved `agents.defaults.timeFormat`; "auto" keeps the browser locale default. */ timeFormat?: "auto" | "12" | "24"; + /** + * Whether the operator terminal surface is enabled (`gateway.terminal.enabled`). + * The Control UI hides the terminal entirely when false so a disabled kill + * switch removes the surface rather than showing a button that errors on open. + */ + terminalEnabled?: boolean; }; diff --git a/src/gateway/control-ui-csp.test.ts b/src/gateway/control-ui-csp.test.ts index 34f19cc4a989..51bcfdb4ae84 100644 --- a/src/gateway/control-ui-csp.test.ts +++ b/src/gateway/control-ui-csp.test.ts @@ -65,6 +65,30 @@ describe("buildControlUiCspHeader", () => { const csp = buildControlUiCspHeader({ inlineScriptHashes: [] }); expect(csp).toMatch(/script-src 'self'(?:;|$)/); }); + + it("does not relax the policy for the terminal unless allowWasm is set", () => { + const csp = buildControlUiCspHeader(); + expect(csp).not.toContain("wasm-unsafe-eval"); + expect(csp).not.toMatch(/connect-src[^;]*data:/); + }); + + it("relaxes script-src and connect-src for the terminal's ghostty-web WASM engine", () => { + const csp = buildControlUiCspHeader({ allowWasm: true }); + // Narrow WASM compilation permission — never full unsafe-eval. + expect(csp).toMatch(/script-src[^;]*'wasm-unsafe-eval'/); + expect(csp).not.toMatch(/script-src[^;]*'unsafe-eval'(?!-)/); + // ghostty-web fetches its inlined WASM from a data: URL. + expect(csp).toMatch(/connect-src[^;]*\bdata:/); + }); + + it("keeps inline script hashes alongside the wasm relaxation", () => { + const csp = buildControlUiCspHeader({ + inlineScriptHashes: ["sha256-abc123"], + allowWasm: true, + }); + expect(csp).toContain("'sha256-abc123'"); + expect(csp).toContain("'wasm-unsafe-eval'"); + }); }); describe("computeInlineScriptHashes", () => { diff --git a/src/gateway/control-ui-csp.ts b/src/gateway/control-ui-csp.ts index 84a987b9476a..8cdadd7cda66 100644 --- a/src/gateway/control-ui-csp.ts +++ b/src/gateway/control-ui-csp.ts @@ -35,22 +35,39 @@ function hasScriptSrcAttribute(openTag: string): boolean { } /** Build the CSP header applied to Gateway-served Control UI HTML. */ -export function buildControlUiCspHeader(opts?: { inlineScriptHashes?: string[] }): string { +export function buildControlUiCspHeader(opts?: { + inlineScriptHashes?: string[]; + /** + * Relax the policy just enough for the embedded terminal's ghostty-web engine: + * `'wasm-unsafe-eval'` permits WebAssembly compilation and `data:` in + * connect-src lets it fetch its inlined WASM binary. Gated on the terminal + * being enabled so the baseline Control UI CSP stays tight otherwise. + */ + allowWasm?: boolean; +}): string { const hashes = opts?.inlineScriptHashes; - const scriptSrc = hashes?.length - ? `script-src 'self' ${hashes.map((h) => `'${h}'`).join(" ")}` - : "script-src 'self'"; + const scriptTokens = ["'self'"]; + if (hashes?.length) { + scriptTokens.push(...hashes.map((h) => `'${h}'`)); + } + if (opts?.allowWasm) { + scriptTokens.push("'wasm-unsafe-eval'"); + } + const connectTokens = ["'self'", "ws:", "wss:", "https://api.openai.com", "https://tweakcn.com"]; + if (opts?.allowWasm) { + connectTokens.push("data:"); + } return [ "default-src 'self'", "base-uri 'none'", "object-src 'none'", "frame-ancestors 'none'", - scriptSrc, + `script-src ${scriptTokens.join(" ")}`, "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "img-src 'self' data: blob:", "media-src 'self' data: blob:", "font-src 'self' https://fonts.gstatic.com", "worker-src 'self'", - "connect-src 'self' ws: wss: https://api.openai.com https://tweakcn.com", + `connect-src ${connectTokens.join(" ")}`, ].join("; "); } diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index a344190d657b..530c68135c41 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -758,15 +758,20 @@ function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) res.end(body); } -function serveResolvedIndexHtml(res: ServerResponse, body: string, basePath?: string) { +function serveResolvedIndexHtml( + res: ServerResponse, + body: string, + basePath?: string, + allowWasm?: boolean, +) { const prepared = rewriteControlUiIndexHtmlPublicAssetHrefs(body, basePath ?? ""); const hashes = computeInlineScriptHashes(prepared); - if (hashes.length > 0) { - res.setHeader( - "Content-Security-Policy", - buildControlUiCspHeader({ inlineScriptHashes: hashes }), - ); - } + // Always set the document CSP here (the index carries inline scripts) so the + // terminal's WASM relaxation is applied to the page that loads ghostty-web. + res.setHeader( + "Content-Security-Policy", + buildControlUiCspHeader({ inlineScriptHashes: hashes, allowWasm }), + ); res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); res.end(prepared); @@ -921,6 +926,9 @@ export async function handleControlUiHttpRequest( const url = new URL(urlRaw, "http://localhost"); const basePath = normalizeControlUiBasePath(opts?.basePath); const pathname = url.pathname; + // The embedded terminal ships ghostty-web (WASM); relax the index CSP only + // when the terminal is enabled (default true). + const terminalEnabled = opts?.config?.gateway?.terminal?.enabled ?? true; const route = classifyControlUiRequest({ basePath, pathname, @@ -997,6 +1005,7 @@ export async function handleControlUiHttpRequest( chatMessageMaxWidth: config?.gateway?.controlUi?.chatMessageMaxWidth, seamColor: config?.ui?.seamColor, timeFormat: config?.agents?.defaults?.timeFormat, + terminalEnabled: config?.gateway?.terminal?.enabled ?? true, } satisfies ControlUiBootstrapConfig); return true; } @@ -1086,7 +1095,12 @@ export async function handleControlUiHttpRequest( return true; } if (path.basename(safeFile.path) === "index.html") { - serveResolvedIndexHtml(res, await readOpenedFileText(safeFile.fd), basePath); + serveResolvedIndexHtml( + res, + await readOpenedFileText(safeFile.fd), + basePath, + terminalEnabled, + ); return true; } serveResolvedFile(res, safeFile.path, await readOpenedFile(safeFile.fd)); @@ -1114,7 +1128,12 @@ export async function handleControlUiHttpRequest( if (respondHeadForFile(req, res, safeIndex.path)) { return true; } - serveResolvedIndexHtml(res, await readOpenedFileText(safeIndex.fd), basePath); + serveResolvedIndexHtml( + res, + await readOpenedFileText(safeIndex.fd), + basePath, + terminalEnabled, + ); return true; } finally { fs.closeSync(safeIndex.fd); diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 1c7a72a6fe0a..b98341233fa2 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -210,6 +210,12 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "chat.message.get", scope: "operator.read", startup: true }, { name: "chat.abort", scope: "operator.write" }, { name: "chat.send", scope: "operator.write" }, + // Operator terminal: admin-only PTY surface. Appended to the advertised block + // so existing advertised method indices stay stable for older clients. + { name: "terminal.open", scope: "operator.admin" }, + { name: "terminal.input", scope: "operator.admin" }, + { name: "terminal.resize", scope: "operator.admin" }, + { name: "terminal.close", scope: "operator.admin" }, { name: "assistant.media.get", scope: "operator.read", advertise: false }, { name: "sessions.get", scope: "operator.read", advertise: false }, { name: "sessions.resolve", scope: "operator.read", advertise: false }, diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index 8d90f21f3e9b..4500e5078898 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -48,6 +48,10 @@ const EVENT_SCOPE_GUARDS: Record = { "session.message": [READ_SCOPE], "session.operation": [READ_SCOPE], "session.tool": [READ_SCOPE], + // Operator terminal byte/exit streams. Admin-gated to match the terminal.* + // methods; also targeted to the owning connection at broadcast time. + "terminal.data": [ADMIN_SCOPE], + "terminal.exit": [ADMIN_SCOPE], }; // Events that node-role sessions must receive even when the event's operator diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 07008be349fe..843ab8358efd 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -63,5 +63,7 @@ export const GATEWAY_EVENTS = [ "exec.approval.resolved", "plugin.approval.requested", "plugin.approval.resolved", + "terminal.data", + "terminal.exit", GATEWAY_EVENT_UPDATE_AVAILABLE, ]; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index ecc2bfeac735..f86954750741 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -134,6 +134,10 @@ const loadLogsHandlers = lazyHandlerModule( () => import("./server-methods/logs.js"), (module) => module.logsHandlers, ); +const loadTerminalHandlers = lazyHandlerModule( + () => import("./server-methods/terminal.js"), + (module) => module.terminalHandlers, +); const loadModelsAuthStatusHandlers = lazyHandlerModule( () => import("./server-methods/models-auth-status.js"), (module) => module.modelsAuthStatusHandlers, @@ -287,6 +291,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["logs.tail"], loadHandlers: loadLogsHandlers, }), + ...createLazyCoreHandlers({ + methods: ["terminal.open", "terminal.input", "terminal.resize", "terminal.close"], + loadHandlers: loadTerminalHandlers, + }), ...createLazyCoreHandlers({ methods: ["voicewake.get", "voicewake.set"], loadHandlers: loadVoicewakeHandlers, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 8aa65378daa7..75c980bfbe6e 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -29,6 +29,7 @@ import type { } from "../server-chat-state.js"; import type { DedupeEntry } from "../server-shared.js"; import type { GatewayEventLoopHealth } from "../server/event-loop-health.js"; +import type { TerminalSessionManager } from "../terminal/session-manager.js"; /** * Shared gateway request types used by every server-method module. @@ -101,6 +102,9 @@ export type GatewayRequestContext = { disconnectClientsUsingSharedGatewayAuth?: () => void; enforceSharedGatewayAuthGenerationForConfigWrite?: (nextConfig: OpenClawConfig) => void; nodeRegistry: NodeRegistry; + // Operator terminal session store. Absent in local/in-process contexts where + // no PTY surface is served. + terminalSessions?: TerminalSessionManager; agentRunSeq: Map; chatAbortControllers: Map; chatAbortedRuns: Map; diff --git a/src/gateway/server-methods/terminal.test.ts b/src/gateway/server-methods/terminal.test.ts new file mode 100644 index 000000000000..2909f782b570 --- /dev/null +++ b/src/gateway/server-methods/terminal.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { terminalHandlers } from "./terminal.js"; + +function makeOpts(params: unknown, terminalConfig: { enabled?: boolean } | undefined) { + const sessions = { + write: vi.fn(() => true), + resize: vi.fn(() => true), + close: vi.fn(() => true), + }; + const respond = vi.fn(); + const context = { + getRuntimeConfig: () => ({ gateway: { terminal: terminalConfig } }) as OpenClawConfig, + terminalSessions: sessions, + // Only the fields the terminal handlers touch are needed here. + } as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]["context"]; + const opts = { + params: params as Record, + respond, + context, + client: { connId: "conn-1", connect: {} }, + } as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]; + return { opts, sessions, respond }; +} + +describe("terminal.input kill switch", () => { + it("writes to the session when the terminal is enabled", async () => { + const { opts, sessions, respond } = makeOpts( + { sessionId: "s1", data: "ls\n" }, + { enabled: true }, + ); + await terminalHandlers["terminal.input"](opts); + expect(sessions.write).toHaveBeenCalledWith("conn-1", "s1", "ls\n"); + expect(respond).toHaveBeenCalledWith(true, { ok: true }); + }); + + it("closes the session and rejects input when the terminal is disabled", async () => { + const { opts, sessions, respond } = makeOpts( + { sessionId: "s1", data: "ls\n" }, + { enabled: false }, + ); + await terminalHandlers["terminal.input"](opts); + // The disabled kill switch must stop live input and tear the session down. + expect(sessions.write).not.toHaveBeenCalled(); + expect(sessions.close).toHaveBeenCalledWith("conn-1", "s1"); + expect(respond).toHaveBeenCalledWith(true, { ok: false }); + }); +}); + +describe("terminal.resize kill switch", () => { + it("rejects and closes when disabled", async () => { + const { opts, sessions, respond } = makeOpts( + { sessionId: "s1", cols: 80, rows: 24 }, + { enabled: false }, + ); + await terminalHandlers["terminal.resize"](opts); + expect(sessions.resize).not.toHaveBeenCalled(); + expect(sessions.close).toHaveBeenCalledWith("conn-1", "s1"); + expect(respond).toHaveBeenCalledWith(true, { ok: false }); + }); +}); diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts new file mode 100644 index 000000000000..7fbb141ade47 --- /dev/null +++ b/src/gateway/server-methods/terminal.ts @@ -0,0 +1,175 @@ +// Operator terminal gateway methods: open a PTY shell bound to the caller's +// connection, then stream input/resize/close over the same WebSocket. All +// methods require admin scope (enforced by the descriptor table); this module +// re-checks that the feature is enabled and that isolation permits a host shell. +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateTerminalCloseParams, + validateTerminalInputParams, + validateTerminalOpenParams, + validateTerminalResizeParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { buildTerminalEnv, resolveTerminalLaunch } from "../terminal/launch.js"; +import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; + +function invalid(respond: GatewayRequestHandlerOptions["respond"], detail: string): void { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, detail)); +} + +function requireConnId(opts: GatewayRequestHandlerOptions): string | null { + const connId = opts.client?.connId; + if (!connId) { + invalid(opts.respond, "terminal requires an authenticated connection"); + return null; + } + return connId; +} + +function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): boolean { + return context.getRuntimeConfig().gateway?.terminal?.enabled ?? true; +} + +/** Handlers for the operator terminal method family. */ +export const terminalHandlers: GatewayRequestHandlers = { + "terminal.open": async (opts) => { + const { params, respond, context } = opts; + if (!validateTerminalOpenParams(params)) { + invalid( + respond, + `invalid terminal.open params: ${formatValidationErrors(validateTerminalOpenParams.errors)}`, + ); + return; + } + const connId = requireConnId(opts); + if (!connId) { + return; + } + const manager = context.terminalSessions; + if (!manager) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available")); + return; + } + const cfg = context.getRuntimeConfig(); + const terminalCfg = cfg.gateway?.terminal; + const enabled = terminalCfg?.enabled ?? true; + const p = params as { agentId?: string; cols: number; rows: number }; + + const launch = resolveTerminalLaunch({ + config: cfg, + enabled, + agentId: p.agentId, + configuredShell: terminalCfg?.shell, + }); + if (!launch.ok) { + if (launch.block.kind === "disabled") { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); + return; + } + // Fail closed: a sandboxed agent must never receive a host shell. + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `terminal unavailable: agent "${launch.block.agentId}" runs in a sandbox (mode "${launch.block.mode}"); in-sandbox terminals are not supported yet`, + ), + ); + return; + } + + const outcome = await manager.open({ + connId, + agentId: launch.plan.agentId, + cwd: launch.plan.cwd, + shell: launch.plan.shell, + args: launch.plan.args, + cols: p.cols, + rows: p.rows, + env: buildTerminalEnv(process.env), + }); + if (!outcome.ok) { + const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE; + respond(false, undefined, errorShape(code, outcome.message)); + return; + } + context.logGateway.info( + `terminal opened session=${outcome.sessionId} agent=${outcome.agentId} conn=${connId} shell=${outcome.shell}`, + ); + respond(true, { + sessionId: outcome.sessionId, + agentId: outcome.agentId, + shell: outcome.shell, + cwd: outcome.cwd, + confined: false, + }); + }, + + "terminal.input": async (opts) => { + const { params, respond, context } = opts; + if (!validateTerminalInputParams(params)) { + invalid( + respond, + `invalid terminal.input params: ${formatValidationErrors(validateTerminalInputParams.errors)}`, + ); + return; + } + const connId = requireConnId(opts); + if (!connId) { + return; + } + const p = params as { sessionId: string; data: string }; + // Defense-in-depth for an RCE-class surface: disabling the terminal + // restarts the gateway, but the runtime config snapshot flips first, so + // re-checking here cuts keystrokes to live PTYs before the restart lands. + if (!terminalEnabled(context)) { + context.terminalSessions?.close(connId, p.sessionId); + respond(true, { ok: false }); + return; + } + const ok = context.terminalSessions?.write(connId, p.sessionId, p.data) ?? false; + respond(true, { ok }); + }, + + "terminal.resize": async (opts) => { + const { params, respond, context } = opts; + if (!validateTerminalResizeParams(params)) { + invalid( + respond, + `invalid terminal.resize params: ${formatValidationErrors(validateTerminalResizeParams.errors)}`, + ); + return; + } + const connId = requireConnId(opts); + if (!connId) { + return; + } + const p = params as { sessionId: string; cols: number; rows: number }; + if (!terminalEnabled(context)) { + context.terminalSessions?.close(connId, p.sessionId); + respond(true, { ok: false }); + return; + } + const ok = context.terminalSessions?.resize(connId, p.sessionId, p.cols, p.rows) ?? false; + respond(true, { ok }); + }, + + "terminal.close": async (opts) => { + const { params, respond, context } = opts; + if (!validateTerminalCloseParams(params)) { + invalid( + respond, + `invalid terminal.close params: ${formatValidationErrors(validateTerminalCloseParams.errors)}`, + ); + return; + } + const connId = requireConnId(opts); + if (!connId) { + return; + } + const p = params as { sessionId: string }; + const ok = context.terminalSessions?.close(connId, p.sessionId) ?? false; + respond(true, { ok }); + }, +}; diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 2d3ed8412a7c..e79a27e38fed 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -36,6 +36,7 @@ export type GatewayRequestContextParams = { clients: Set; enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void; nodeRegistry: GatewayRequestContext["nodeRegistry"]; + terminalSessions?: GatewayRequestContext["terminalSessions"]; agentRunSeq: GatewayRequestContext["agentRunSeq"]; chatAbortControllers: GatewayRequestContext["chatAbortControllers"]; chatAbortedRuns: GatewayRequestContext["chatAbortedRuns"]; @@ -177,6 +178,7 @@ export function createGatewayRequestContext( enforceSharedGatewayAuthGenerationForConfigWrite: params.enforceSharedGatewayAuthGenerationForConfigWrite, nodeRegistry: params.nodeRegistry, + terminalSessions: params.terminalSessions, agentRunSeq: params.agentRunSeq, chatAbortControllers: params.chatAbortControllers, chatAbortedRuns: params.chatAbortedRuns, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b48dda8bc790..a6e09101d516 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -940,6 +940,12 @@ export async function startGatewayServer( broadcastVoiceWakeChanged, hasTalkNodeConnected, } = createGatewayNodeSessionRuntime({ broadcast }); + const { TerminalSessionManager } = await import("./terminal/session-manager.js"); + // One PTY store per gateway. Emits each session's bytes only to the owning + // connection so terminals stay private to the operator that opened them. + const terminalSessions = new TerminalSessionManager({ + emit: (connId, event, payload) => broadcastToConnIds(event, payload, new Set([connId])), + }); applyGatewayLaneConcurrency(cfgAtStart); runtimeState = createGatewayServerLiveState({ @@ -1446,6 +1452,7 @@ export async function startGatewayServer( }); }, nodeRegistry, + terminalSessions, agentRunSeq, chatAbortControllers, chatAbortedRuns: chatRunState.abortedRuns, @@ -1812,6 +1819,8 @@ export async function startGatewayServer( close: async (optsLocal) => { try { markClosePreludeStarted(); + // Kill any live operator shells before the socket layer tears down. + terminalSessions.disposeAll(); await stopRegisteredGatewayLifetimeSidecars(); await stopRegisteredPostReadySidecars(); // Run gateway_stop plugin hook before shutdown diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 81216bcf075b..c8e2b7c8cd79 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -479,6 +479,9 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti } const context = buildRequestContext(); context.unsubscribeAllSessionEvents(connId); + // Kill any PTY shells this connection owned; an operator's terminals must + // not outlive the socket that opened them. + context.terminalSessions?.closeForConn(connId); let currentDisconnectedNodeId: string | null = null; if (client?.connect?.role === "node") { currentDisconnectedNodeId = context.nodeRegistry.unregister(connId); diff --git a/src/gateway/terminal/launch.test.ts b/src/gateway/terminal/launch.test.ts new file mode 100644 index 000000000000..84779c3e08d6 --- /dev/null +++ b/src/gateway/terminal/launch.test.ts @@ -0,0 +1,108 @@ +import { mkdtempSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { buildTerminalEnv, resolveTerminalLaunch, resolveTerminalShell } from "./launch.js"; + +describe("resolveTerminalShell", () => { + it("prefers an explicitly configured shell", () => { + const resolved = resolveTerminalShell({ + configuredShell: "/usr/bin/fish", + platform: "linux", + env: { SHELL: "/bin/zsh" }, + }); + expect(resolved).toEqual({ shell: "/usr/bin/fish", args: [] }); + }); + + it("uses the unix login shell as a login shell", () => { + const resolved = resolveTerminalShell({ platform: "linux", env: { SHELL: "/bin/zsh" } }); + expect(resolved).toEqual({ shell: "/bin/zsh", args: ["-l"] }); + }); + + it("falls back to bash when no login shell is set", () => { + const resolved = resolveTerminalShell({ platform: "linux", env: {} }); + expect(resolved).toEqual({ shell: "/bin/bash", args: ["-l"] }); + }); + + it("uses ComSpec on windows", () => { + const resolved = resolveTerminalShell({ + platform: "win32", + env: { ComSpec: "C:/Windows/System32/cmd.exe" }, + }); + expect(resolved).toEqual({ shell: "C:/Windows/System32/cmd.exe", args: [] }); + }); +}); + +describe("resolveTerminalLaunch", () => { + it("blocks when the terminal is disabled", () => { + const result = resolveTerminalLaunch({ config: {} as OpenClawConfig, enabled: false }); + expect(result).toEqual({ ok: false, block: { kind: "disabled" } }); + }); + + it("returns a host plan starting in the agent workspace", () => { + const workspace = mkdtempSync(path.join(os.tmpdir(), "term-ws-")); + const config = { + agents: { defaults: { workspace } }, + } as unknown as OpenClawConfig; + const result = resolveTerminalLaunch({ + config, + enabled: true, + env: { SHELL: "/bin/zsh" }, + platform: "linux", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.plan.cwd).toBe(workspace); + expect(result.plan.shell).toBe("/bin/zsh"); + expect(result.plan.args).toEqual(["-l"]); + expect(result.plan.agentId).toBeTruthy(); + } + }); + + it("fails closed for a fully sandboxed (mode: all) agent", () => { + const config = { + agents: { defaults: { sandbox: { mode: "all" } } }, + } as unknown as OpenClawConfig; + const result = resolveTerminalLaunch({ config, enabled: true }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.block.kind).toBe("sandboxed"); + if (result.block.kind === "sandboxed") { + expect(result.block.mode).toBe("all"); + } + } + }); + + it("allows a host terminal under non-main sandbox mode (main session runs on host)", () => { + const workspace = mkdtempSync(path.join(os.tmpdir(), "term-ws-nm-")); + const config = { + agents: { defaults: { workspace, sandbox: { mode: "non-main" } } }, + } as unknown as OpenClawConfig; + const result = resolveTerminalLaunch({ + config, + enabled: true, + env: { SHELL: "/bin/zsh" }, + platform: "linux", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.plan.cwd).toBe(workspace); + } + }); +}); + +describe("buildTerminalEnv", () => { + it("carries the base env, defaults TERM, and marks the terminal", () => { + const env = buildTerminalEnv({ PATH: "/usr/bin", FOO: "bar" }); + expect(env.PATH).toBe("/usr/bin"); + expect(env.FOO).toBe("bar"); + expect(env.TERM).toBe("xterm-256color"); + expect(env.OPENCLAW_TERMINAL).toBe("1"); + }); + + it("preserves an existing TERM", () => { + const env = buildTerminalEnv({ TERM: "screen-256color" }); + expect(env.TERM).toBe("screen-256color"); + }); +}); diff --git a/src/gateway/terminal/launch.ts b/src/gateway/terminal/launch.ts new file mode 100644 index 000000000000..de25721bfb44 --- /dev/null +++ b/src/gateway/terminal/launch.ts @@ -0,0 +1,126 @@ +// Resolves where an operator terminal session should start and whether the +// target agent's workspace isolation permits a host shell. +import { existsSync, statSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope-config.js"; +import { resolveSandboxConfigForAgent } from "../../agents/sandbox/config.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; + +/** Why a terminal cannot open, or `null` when it can. */ +export type TerminalLaunchBlock = + | { kind: "disabled" } + | { kind: "sandboxed"; agentId: string; mode: "all" }; + +/** Resolved plan for a host terminal session. */ +export type TerminalLaunchPlan = { + agentId: string; + cwd: string; + shell: string; + args: string[]; +}; + +/** Terminal launch resolution result: either a runnable plan or a block reason. */ +export type TerminalLaunchResolution = + | { ok: true; plan: TerminalLaunchPlan } + | { ok: false; block: TerminalLaunchBlock }; + +/** Picks the interactive shell: explicit config, then the host login shell. */ +export function resolveTerminalShell(params: { + configuredShell?: string; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; +}): { shell: string; args: string[] } { + const configured = params.configuredShell?.trim(); + if (configured) { + return { shell: configured, args: [] }; + } + const platform = params.platform ?? process.platform; + const env = params.env ?? process.env; + if (platform === "win32") { + return { shell: env.ComSpec?.trim() || "cmd.exe", args: [] }; + } + const loginShell = env.SHELL?.trim(); + if (loginShell) { + // Login flag so the operator lands in the same environment their terminal + // app would give them (profile-sourced PATH, aliases, prompt). + return { shell: loginShell, args: ["-l"] }; + } + return { shell: "/bin/bash", args: ["-l"] }; +} + +/** + * Resolves the terminal launch plan for one agent. + * + * The terminal always starts in the agent workspace. When the agent runs fully + * sandboxed (`sandbox.mode: "all"`), a host shell would escape the isolation the + * agent itself is under, so this returns a `sandboxed` block rather than silently + * handing back an unconfined shell — fail-closed. `"non-main"` keeps the agent's + * main session on the host, so a host terminal is allowed there. + */ +export function resolveTerminalLaunch(params: { + config: OpenClawConfig; + enabled: boolean; + agentId?: string; + configuredShell?: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}): TerminalLaunchResolution { + if (!params.enabled) { + return { ok: false, block: { kind: "disabled" } }; + } + const env = params.env ?? process.env; + const agentId = params.agentId?.trim() || resolveDefaultAgentId(params.config); + const sandbox = resolveSandboxConfigForAgent(params.config, agentId); + // Only "all" sandboxes every session. Under "non-main" the agent's main + // session still runs on the host, so a host terminal there is consistent with + // how the agent already runs (and an admin already has that host access via + // the main session). Block only the fully-sandboxed case; in-sandbox terminals + // are a tracked follow-up. + if (sandbox.mode === "all") { + return { ok: false, block: { kind: "sandboxed", agentId, mode: "all" } }; + } + const workspaceDir = resolveAgentWorkspaceDir(params.config, agentId, env); + const cwd = existingDirOrHome(workspaceDir, env); + const { shell, args } = resolveTerminalShell({ + configuredShell: params.configuredShell, + platform: params.platform, + env, + }); + return { ok: true, plan: { agentId, cwd, shell, args } }; +} + +/** Builds the child environment for a host terminal from the gateway env. */ +export function buildTerminalEnv(baseEnv: NodeJS.ProcessEnv): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === "string") { + env[key] = value; + } + } + env.TERM = env.TERM ?? "xterm-256color"; + // Lets shells and prompts detect that they are inside an OpenClaw terminal. + env.OPENCLAW_TERMINAL = "1"; + return env; +} + +// A workspace dir that has not been created yet would make the PTY spawn fail; +// fall back to the home directory so the terminal still opens. +function existingDirOrHome(dir: string, env: NodeJS.ProcessEnv): string { + const trimmed = dir.trim(); + const home = env.HOME?.trim() || os.homedir(); + if (!trimmed || !path.isAbsolute(trimmed)) { + return home; + } + try { + if (existsSync(trimmed) && statSync(trimmed).isDirectory()) { + return trimmed; + } + } catch { + // Unreadable path: fall through to home rather than fail the spawn. + } + return home; +} diff --git a/src/gateway/terminal/pty.test.ts b/src/gateway/terminal/pty.test.ts new file mode 100644 index 000000000000..0467197aa0bc --- /dev/null +++ b/src/gateway/terminal/pty.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +// vi.mock is hoisted above module scope, so the mock target must be created with +// vi.hoisted for the factory to reference it. +const { signalProcessTree } = vi.hoisted(() => ({ signalProcessTree: vi.fn() })); +vi.mock("../../process/kill-tree.js", () => ({ signalProcessTree })); + +const { killPtyTree } = await import("./pty.js"); + +function fakePty(pid = 4321) { + return { pid, kill: vi.fn() }; +} + +describe("killPtyTree", () => { + it("tears down the whole process tree on the default (SIGKILL) close", () => { + const pty = fakePty(); + killPtyTree(pty); + // Kills the tree, not just the shell — orphaned child commands are reaped. + expect(signalProcessTree).toHaveBeenCalledWith(4321, "SIGKILL"); + expect(pty.kill).not.toHaveBeenCalled(); + }); + + it("uses the process tree for SIGTERM too", () => { + const pty = fakePty(999); + killPtyTree(pty, "SIGTERM"); + expect(signalProcessTree).toHaveBeenCalledWith(999, "SIGTERM"); + }); + + it("falls back to a direct pty kill for non-terminating signals", () => { + const pty = fakePty(); + signalProcessTree.mockClear(); + killPtyTree(pty, "SIGHUP"); + expect(signalProcessTree).not.toHaveBeenCalled(); + expect(pty.kill).toHaveBeenCalledWith("SIGHUP"); + }); + + it("does not throw when the process is already gone", () => { + const pty = { pid: 0, kill: vi.fn() }; + expect(() => killPtyTree(pty)).not.toThrow(); + }); +}); diff --git a/src/gateway/terminal/pty.ts b/src/gateway/terminal/pty.ts new file mode 100644 index 000000000000..6dc2521689f0 --- /dev/null +++ b/src/gateway/terminal/pty.ts @@ -0,0 +1,106 @@ +// Thin, resize-capable PTY wrapper for the operator terminal. +// +// The process supervisor's PTY adapter is shaped for one-shot managed runs and +// hides resize; the operator terminal needs a long-lived, interactive handle, so +// it owns this narrow loader instead of reshaping the supervisor contract. +import { signalProcessTree } from "../../process/kill-tree.js"; +import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js"; + +type PtyDisposable = { dispose: () => void }; + +/** Live PTY handle used by one operator terminal session. */ +export type TerminalPtyHandle = { + pid: number; + write: (data: string) => void; + resize: (cols: number, rows: number) => void; + onData: (listener: (chunk: string) => void) => void; + onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => void; + kill: (signal?: string) => void; +}; + +type PtyForkHandle = { + readonly pid: number; + write: (data: string) => void; + resize: (columns: number, rows: number) => void; + onData: (listener: (value: string) => void) => PtyDisposable | void; + onExit: ( + listener: (value: { exitCode: number; signal?: number }) => void, + ) => PtyDisposable | void; + kill: (signal?: string) => void; +}; + +type PtySpawn = ( + file: string, + args: string[], + options: { + name?: string; + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + }, +) => PtyForkHandle; + +type PtyModule = { spawn?: PtySpawn; default?: { spawn?: PtySpawn } }; + +const loadPtyModule = createLazyRuntimeModule( + () => import("@lydell/node-pty") as Promise as Promise, +); + +/** Spawns a PTY process and adapts it to the terminal session handle. */ +export async function spawnTerminalPty(params: { + file: string; + args: string[]; + cwd?: string; + env: Record; + cols: number; + rows: number; +}): Promise { + const mod = await loadPtyModule(); + const spawn = mod.spawn ?? mod.default?.spawn; + if (!spawn) { + throw new Error("PTY support is unavailable (node-pty spawn not found)."); + } + const pty = spawn(params.file, params.args, { + name: params.env.TERM ?? "xterm-256color", + cols: params.cols, + rows: params.rows, + cwd: params.cwd, + env: params.env, + }); + return { + get pid() { + return pty.pid; + }, + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + onData: (listener) => { + pty.onData(listener); + }, + onExit: (listener) => { + pty.onExit(listener); + }, + kill: (signal) => killPtyTree(pty, signal), + } satisfies TerminalPtyHandle; +} + +// node-pty's kill only signals the shell; commands it launched (a long-running +// `npm install`, `sleep`, etc.) would survive close/disconnect/shutdown. Signal +// the whole process tree instead, mirroring the process supervisor's PTY adapter. +export function killPtyTree( + pty: { pid: number; kill: (signal?: string) => void }, + signal?: string, +): void { + const sig = (signal ?? "SIGKILL") as NodeJS.Signals; + try { + if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) { + signalProcessTree(pty.pid, sig); + } else if (process.platform === "win32") { + pty.kill(); + } else { + pty.kill(sig); + } + } catch { + // Process may already be gone; teardown is best-effort. + } +} diff --git a/src/gateway/terminal/session-manager.test.ts b/src/gateway/terminal/session-manager.test.ts new file mode 100644 index 000000000000..a601e490de86 --- /dev/null +++ b/src/gateway/terminal/session-manager.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TerminalPtyHandle } from "./pty.js"; +import { + TERMINAL_EVENT_DATA, + TERMINAL_EVENT_EXIT, + TerminalSessionManager, + type TerminalOpenRequest, +} from "./session-manager.js"; + +/** A controllable fake PTY that records writes and lets tests drive data/exit. */ +function makeFakePty() { + let dataListener: ((chunk: string) => void) | undefined; + let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined; + const handle: TerminalPtyHandle & { + writes: string[]; + resizes: Array<[number, number]>; + killed: boolean; + emitData: (chunk: string) => void; + emitExit: (code: number, signal?: number) => void; + } = { + pid: 4242, + writes: [], + resizes: [], + killed: false, + write: (data) => handle.writes.push(data), + resize: (cols, rows) => handle.resizes.push([cols, rows]), + onData: (listener) => { + dataListener = listener; + }, + onExit: (listener) => { + exitListener = listener; + }, + kill: () => { + handle.killed = true; + }, + emitData: (chunk) => dataListener?.(chunk), + emitExit: (code, signal) => exitListener?.({ exitCode: code, signal }), + }; + return handle; +} + +function baseRequest(overrides?: Partial): TerminalOpenRequest { + return { + connId: "conn-1", + agentId: "main", + cwd: "/work", + shell: "/bin/zsh", + args: ["-l"], + cols: 80, + rows: 24, + env: { TERM: "xterm-256color" }, + ...overrides, + }; +} + +describe("TerminalSessionManager", () => { + it("opens a session and streams output only to the owning connection", async () => { + const emit = vi.fn(); + const fake = makeFakePty(); + const manager = new TerminalSessionManager({ emit, spawn: async () => fake }); + + const outcome = await manager.open(baseRequest()); + expect(outcome.ok).toBe(true); + if (!outcome.ok) { + return; + } + expect(manager.size).toBe(1); + + fake.emitData("hello"); + fake.emitData("world"); + expect(emit).toHaveBeenNthCalledWith(1, "conn-1", TERMINAL_EVENT_DATA, { + sessionId: outcome.sessionId, + seq: 0, + data: "hello", + }); + expect(emit).toHaveBeenNthCalledWith(2, "conn-1", TERMINAL_EVENT_DATA, { + sessionId: outcome.sessionId, + seq: 1, + data: "world", + }); + }); + + it("routes input and resize to the pty for the owning connection", async () => { + const fake = makeFakePty(); + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => fake }); + const outcome = await manager.open(baseRequest()); + if (!outcome.ok) { + throw new Error("expected open"); + } + + expect(manager.write("conn-1", outcome.sessionId, "ls\n")).toBe(true); + expect(fake.writes).toEqual(["ls\n"]); + expect(manager.resize("conn-1", outcome.sessionId, 120, 40)).toBe(true); + expect(fake.resizes).toEqual([[120, 40]]); + }); + + it("refuses input from a different connection", async () => { + const fake = makeFakePty(); + const manager = new TerminalSessionManager({ emit: vi.fn(), spawn: async () => fake }); + const outcome = await manager.open(baseRequest()); + if (!outcome.ok) { + throw new Error("expected open"); + } + expect(manager.write("conn-2", outcome.sessionId, "rm -rf /\n")).toBe(false); + expect(fake.writes).toEqual([]); + }); + + it("emits an exit event and drops the session when the process exits", async () => { + const emit = vi.fn(); + const fake = makeFakePty(); + const manager = new TerminalSessionManager({ emit, spawn: async () => fake }); + const outcome = await manager.open(baseRequest()); + if (!outcome.ok) { + throw new Error("expected open"); + } + + fake.emitExit(0); + expect(manager.size).toBe(0); + expect(emit).toHaveBeenCalledWith("conn-1", TERMINAL_EVENT_EXIT, { + sessionId: outcome.sessionId, + exitCode: 0, + signal: null, + reason: "process_exit", + }); + expect(fake.killed).toBe(true); + }); + + it("kills every session a disconnected connection owned without emitting", async () => { + const emit = vi.fn(); + const ptys = [makeFakePty(), makeFakePty()]; + let idx = 0; + const manager = new TerminalSessionManager({ emit, spawn: async () => ptys[idx++] }); + await manager.open(baseRequest()); + await manager.open(baseRequest()); + expect(manager.size).toBe(2); + emit.mockClear(); + + manager.closeForConn("conn-1"); + expect(manager.size).toBe(0); + expect(ptys[0].killed).toBe(true); + expect(ptys[1].killed).toBe(true); + // Silent teardown: the socket is already gone. + expect(emit).not.toHaveBeenCalled(); + }); + + it("disposes every session silently (gateway shutdown)", async () => { + const emit = vi.fn(); + const ptys = [makeFakePty(), makeFakePty()]; + let idx = 0; + const manager = new TerminalSessionManager({ emit, spawn: async () => ptys[idx++] }); + await manager.open(baseRequest()); + await manager.open(baseRequest({ connId: "conn-2" })); + emit.mockClear(); + + manager.disposeAll(); + expect(manager.size).toBe(0); + expect(ptys[0].killed).toBe(true); + expect(ptys[1].killed).toBe(true); + // Shutdown drops the sockets, so notifying clients is pointless. + expect(emit).not.toHaveBeenCalled(); + }); + + it("enforces the session limit", async () => { + const manager = new TerminalSessionManager({ + emit: vi.fn(), + spawn: async () => makeFakePty(), + maxSessions: 1, + }); + const first = await manager.open(baseRequest()); + expect(first.ok).toBe(true); + const second = await manager.open(baseRequest()); + expect(second.ok).toBe(false); + if (!second.ok) { + expect(second.code).toBe("limit"); + } + }); + + it("kills a pending open whose connection disconnects during spawn", async () => { + const emit = vi.fn(); + const fake = makeFakePty(); + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const manager = new TerminalSessionManager({ + emit, + spawn: async () => { + await gate; + return fake; + }, + }); + const openPromise = manager.open(baseRequest({ connId: "conn-x" })); + // Connection drops while the shell is still spawning. + manager.closeForConn("conn-x"); + release?.(); + const outcome = await openPromise; + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe("closed"); + } + // The freshly spawned PTY is killed, not registered as an orphan. + expect(fake.killed).toBe(true); + expect(manager.size).toBe(0); + }); + + it("enforces the cap against concurrent opens racing on the async spawn", async () => { + // Spawn resolves on a later tick so both opens await it before either registers. + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + const manager = new TerminalSessionManager({ + emit: vi.fn(), + spawn: async () => { + await gate; + return makeFakePty(); + }, + maxSessions: 1, + }); + const both = Promise.all([manager.open(baseRequest()), manager.open(baseRequest())]); + release?.(); + const [a, b] = await both; + // Exactly one succeeds; the reserved slot blocks the concurrent open. + expect([a.ok, b.ok].filter(Boolean)).toHaveLength(1); + expect(manager.size).toBe(1); + }); + + it("reports a spawn failure instead of throwing", async () => { + const manager = new TerminalSessionManager({ + emit: vi.fn(), + spawn: async () => { + throw new Error("node-pty missing"); + }, + }); + const outcome = await manager.open(baseRequest()); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe("spawn_failed"); + expect(outcome.message).toContain("node-pty missing"); + } + }); +}); diff --git a/src/gateway/terminal/session-manager.ts b/src/gateway/terminal/session-manager.ts new file mode 100644 index 000000000000..0fac86a8d429 --- /dev/null +++ b/src/gateway/terminal/session-manager.ts @@ -0,0 +1,307 @@ +// Owns the lifecycle of operator terminal sessions: one PTY per open, bound to +// the connection that opened it, streamed back over the gateway event channel. +import { randomUUID } from "node:crypto"; +import { spawnTerminalPty, type TerminalPtyHandle } from "./pty.js"; + +/** Emits one terminal event frame to the single owning connection. */ +export type TerminalEventSink = (connId: string, event: string, payload: unknown) => void; + +/** Injectable PTY spawner so tests can drive sessions without a real shell. */ +export type TerminalSpawner = typeof spawnTerminalPty; + +export const TERMINAL_EVENT_DATA = "terminal.data" as const; +export const TERMINAL_EVENT_EXIT = "terminal.exit" as const; + +type TerminalExitReason = "process_exit" | "closed" | "disconnected" | "error"; + +type TerminalSession = { + id: string; + connId: string; + agentId: string; + cwd: string; + shell: string; + pty: TerminalPtyHandle; + seq: number; + closed: boolean; +}; + +/** Bounds concurrent shells so a client cannot exhaust host processes. */ +const DEFAULT_MAX_SESSIONS = 24; + +export type TerminalSessionManagerOptions = { + emit: TerminalEventSink; + spawn?: TerminalSpawner; + maxSessions?: number; + env?: NodeJS.ProcessEnv; +}; + +/** Parameters for a resolved host terminal launch (isolation already checked). */ +export type TerminalOpenRequest = { + connId: string; + agentId: string; + cwd: string; + shell: string; + args: string[]; + cols: number; + rows: number; + env: Record; +}; + +export type TerminalOpenOutcome = + | { ok: true; sessionId: string; agentId: string; cwd: string; shell: string } + | { ok: false; code: "limit" | "spawn_failed" | "closed"; message: string }; + +/** Abort flag shared between a pending open and its connection's disconnect. */ +type OpenToken = { aborted: boolean }; + +/** + * Tracks live PTY sessions keyed by session id, with a reverse index by + * connection so a disconnect can tear down every shell it owned. + */ +export class TerminalSessionManager { + private readonly sessions = new Map(); + private readonly byConn = new Map>(); + // Opens still awaiting spawn, keyed by connection. A disconnect flips their + // abort flag so the resumed open kills the PTY instead of registering an + // orphan for a dead connection. + private readonly pendingOpens = new Map>(); + private readonly emit: TerminalEventSink; + private readonly spawn: TerminalSpawner; + private readonly maxSessions: number; + // Slots reserved by opens that are still awaiting spawn. Counted against the + // cap so concurrent opens cannot all pass the check and exceed maxSessions. + private opening = 0; + + constructor(options: TerminalSessionManagerOptions) { + this.emit = options.emit; + this.spawn = options.spawn ?? spawnTerminalPty; + this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; + } + + /** Number of live sessions; used by tests and health surfaces. */ + get size(): number { + return this.sessions.size; + } + + /** Spawns a shell and wires its output/exit to the owning connection. */ + async open(request: TerminalOpenRequest): Promise { + if (this.sessions.size + this.opening >= this.maxSessions) { + return { + ok: false, + code: "limit", + message: `terminal session limit reached (${this.maxSessions})`, + }; + } + // Reserve the slot before the async spawn so it is visible to concurrent opens. + this.opening += 1; + const token: OpenToken = { aborted: false }; + this.trackPendingOpen(request.connId, token); + let pty: TerminalPtyHandle; + try { + pty = await this.spawn({ + file: request.shell, + args: request.args, + cwd: request.cwd, + env: request.env, + cols: request.cols, + rows: request.rows, + }); + } catch (err) { + this.opening -= 1; + this.untrackPendingOpen(request.connId, token); + return { ok: false, code: "spawn_failed", message: String((err as Error)?.message ?? err) }; + } + // Hand the reservation over to the live session (synchronous from here — no + // await — so the counts never both drop). + this.opening -= 1; + this.untrackPendingOpen(request.connId, token); + if (token.aborted) { + // The owning connection disconnected while the shell was spawning; kill it + // now rather than register an orphan no one can reach or close. + try { + pty.kill(); + } catch { + // Best-effort; the process may already be gone. + } + return { ok: false, code: "closed", message: "connection closed during open" }; + } + + const session: TerminalSession = { + id: randomUUID(), + connId: request.connId, + agentId: request.agentId, + cwd: request.cwd, + shell: request.shell, + pty, + seq: 0, + closed: false, + }; + this.sessions.set(session.id, session); + let connSessions = this.byConn.get(request.connId); + if (!connSessions) { + connSessions = new Set(); + this.byConn.set(request.connId, connSessions); + } + connSessions.add(session.id); + + pty.onData((chunk) => { + if (session.closed) { + return; + } + this.emit(session.connId, TERMINAL_EVENT_DATA, { + sessionId: session.id, + seq: session.seq++, + data: chunk, + }); + }); + pty.onExit((event) => { + const signal = event.signal && event.signal !== 0 ? event.signal : null; + this.finalize(session, "process_exit", { exitCode: event.exitCode ?? null, signal }); + }); + + return { + ok: true, + sessionId: session.id, + agentId: session.agentId, + cwd: session.cwd, + shell: session.shell, + }; + } + + /** Writes client input to a session; returns false when the session is gone. */ + write(connId: string, sessionId: string, data: string): boolean { + const session = this.ownedSession(connId, sessionId); + if (!session) { + return false; + } + try { + session.pty.write(data); + return true; + } catch { + this.finalize(session, "error", { error: "write failed" }); + return false; + } + } + + /** Applies a new PTY grid size; returns false when the session is gone. */ + resize(connId: string, sessionId: string, cols: number, rows: number): boolean { + const session = this.ownedSession(connId, sessionId); + if (!session) { + return false; + } + try { + session.pty.resize(cols, rows); + return true; + } catch { + return false; + } + } + + /** Closes one session on operator request. */ + close(connId: string, sessionId: string): boolean { + const session = this.ownedSession(connId, sessionId); + if (!session) { + return false; + } + this.finalize(session, "closed", {}); + return true; + } + + private trackPendingOpen(connId: string, token: OpenToken): void { + let set = this.pendingOpens.get(connId); + if (!set) { + set = new Set(); + this.pendingOpens.set(connId, set); + } + set.add(token); + } + + private untrackPendingOpen(connId: string, token: OpenToken): void { + const set = this.pendingOpens.get(connId); + if (set) { + set.delete(token); + if (set.size === 0) { + this.pendingOpens.delete(connId); + } + } + } + + /** Tears down every session a disconnected connection owned. */ + closeForConn(connId: string): void { + // Abort opens still awaiting spawn so they don't register orphaned PTYs. + const opens = this.pendingOpens.get(connId); + if (opens) { + for (const token of opens) { + token.aborted = true; + } + } + const ids = this.byConn.get(connId); + if (!ids) { + return; + } + // Copy ids first: finalize() mutates the same set during iteration. + for (const id of [...ids]) { + const session = this.sessions.get(id); + if (session) { + this.finalize(session, "disconnected", {}, { silent: true }); + } + } + this.byConn.delete(connId); + } + + /** Kills every session; used on gateway shutdown. */ + /** + * Tears down every session on gateway shutdown/stop. Silent because the + * sockets are going away anyway (disabling the terminal is a `gateway` + * restart, so that path also runs through here, not a live notification). + */ + disposeAll(): void { + // Abort any opens still spawning so they don't register after shutdown. + for (const opens of this.pendingOpens.values()) { + for (const token of opens) { + token.aborted = true; + } + } + for (const session of [...this.sessions.values()]) { + this.finalize(session, "disconnected", {}, { silent: true }); + } + } + + private ownedSession(connId: string, sessionId: string): TerminalSession | undefined { + const session = this.sessions.get(sessionId); + if (!session || session.connId !== connId || session.closed) { + return undefined; + } + return session; + } + + private finalize( + session: TerminalSession, + reason: TerminalExitReason, + detail: { exitCode?: number | null; signal?: number | null; error?: string }, + opts?: { silent?: boolean }, + ): void { + if (session.closed) { + return; + } + session.closed = true; + this.sessions.delete(session.id); + this.byConn.get(session.connId)?.delete(session.id); + try { + session.pty.kill(); + } catch { + // Process may already be gone; the kill is best-effort teardown. + } + // A disconnect already dropped the socket, so emitting there is pointless; + // process/close/error exits still notify the live client. + if (!opts?.silent) { + this.emit(session.connId, TERMINAL_EVENT_EXIT, { + sessionId: session.id, + exitCode: detail.exitCode ?? null, + signal: detail.signal ?? null, + reason, + ...(detail.error ? { error: detail.error } : {}), + }); + } + } +}