mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
feat(gateway): embedded operator terminal (PTY) with fail-closed isolation and kill switch
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("; ");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user