feat(gateway): embedded operator terminal (PTY) with fail-closed isolation and kill switch

This commit is contained in:
Peter Steinberger
2026-07-04 01:25:20 -07:00
parent 66015762a3
commit f083f35ddb
26 changed files with 1336 additions and 15 deletions
+6
View File
@@ -92,6 +92,12 @@ export const FIELD_HELP: Record<string, string> = {
"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":
+3
View File
@@ -122,6 +122,9 @@ export const FIELD_LABELS: Record<string, string> = {
"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",
+21
View File
@@ -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;
+7
View File
@@ -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
+5
View File
@@ -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",
+9
View File
@@ -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);
+6
View File
@@ -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;
};
+24
View File
@@ -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", () => {
+23 -6
View File
@@ -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("; ");
}
+28 -9
View File
@@ -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);
+6
View File
@@ -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 },
+4
View File
@@ -48,6 +48,10 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
"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
+2
View File
@@ -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,
];
+8
View File
@@ -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,
@@ -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<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatAbortedRuns: Map<string, ChatAbortMarker>;
@@ -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<string, unknown>,
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 });
});
});
+175
View File
@@ -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 });
},
};
+2
View File
@@ -36,6 +36,7 @@ export type GatewayRequestContextParams = {
clients: Set<GatewayRequestContextClient>;
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,
+9
View File
@@ -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
+3
View File
@@ -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);
+108
View File
@@ -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");
});
});
+126
View File
@@ -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<string, string> {
const env: Record<string, string> = {};
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;
}
+41
View File
@@ -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();
});
});
+106
View File
@@ -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<string, string>;
},
) => PtyForkHandle;
type PtyModule = { spawn?: PtySpawn; default?: { spawn?: PtySpawn } };
const loadPtyModule = createLazyRuntimeModule(
() => import("@lydell/node-pty") as Promise<unknown> as Promise<PtyModule>,
);
/** 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<string, string>;
cols: number;
rows: number;
}): Promise<TerminalPtyHandle> {
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.
}
}
@@ -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>): 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<void>((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<void>((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");
}
});
});
+307
View File
@@ -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<string, string>;
};
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<string, TerminalSession>();
private readonly byConn = new Map<string, Set<string>>();
// 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<string, Set<OpenToken>>();
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<TerminalOpenOutcome> {
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 } : {}),
});
}
}
}