From 7e8ea61b0886e27f827504a39d21bab218fd2374 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 16:34:07 -0400 Subject: [PATCH] fix(gateway): harden embedded terminal policy (#100081) * fix(gateway): harden operator terminal policy * test(gateway): complete terminal context coverage * test(gateway): narrow terminal policy mock * test(gateway): satisfy CSP header lint * docs: refresh terminal docs map * test: route terminal temp helper importer --- docs/docs_map.md | 1 + docs/gateway/configuration-reference.md | 6 + docs/gateway/protocol.md | 8 + docs/web/control-ui.md | 10 + src/config/schema.help.ts | 2 +- src/config/types.gateway.ts | 2 +- src/gateway/config-reload.test.ts | 133 ++++++++++- src/gateway/config-reload.ts | 29 ++- src/gateway/control-ui-contract.ts | 3 + src/gateway/control-ui.auto-root.http.test.ts | 4 +- src/gateway/control-ui.http.test.ts | 65 +++++- src/gateway/control-ui.ts | 17 +- src/gateway/local-request-context.ts | 2 + src/gateway/server-http.ts | 3 + src/gateway/server-methods/shared-types.ts | 3 + src/gateway/server-methods/terminal.test.ts | 59 ++++- src/gateway/server-methods/terminal.ts | 15 +- src/gateway/server-reload-handlers.test.ts | 6 + src/gateway/server-reload-handlers.ts | 4 + src/gateway/server-request-context.test.ts | 5 + src/gateway/server-request-context.ts | 4 + src/gateway/server-runtime-state.ts | 2 + src/gateway/server.impl.ts | 12 + src/gateway/terminal/launch.test.ts | 213 +++++++++++++++++- src/gateway/terminal/launch.ts | 133 +++++++++++ src/gateway/terminal/session-manager.test.ts | 41 ++++ src/gateway/terminal/session-manager.ts | 35 ++- .../test-helpers.server-runtime-state.ts | 1 + src/scripts/test-projects.test.ts | 1 + ui/src/ui/app-render.ts | 1 + ui/src/ui/app.ts | 8 +- .../controllers/control-ui-bootstrap.test.ts | 71 +++++- ui/src/ui/controllers/control-ui-bootstrap.ts | 22 +- .../ui/terminal/terminal-connection.test.ts | 14 ++ ui/src/ui/terminal/terminal-panel.test.ts | 67 ++++++ ui/src/ui/terminal/terminal-panel.ts | 6 +- 36 files changed, 940 insertions(+), 68 deletions(-) create mode 100644 ui/src/ui/terminal/terminal-panel.test.ts diff --git a/docs/docs_map.md b/docs/docs_map.md index 5b1883c0872f..54fe2d692041 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -9817,6 +9817,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What it can do (today) - H2: MCP page - H2: Activity tab + - H2: Operator terminal - H2: Chat behavior - H2: PWA install and web push - H2: Hosted embeds diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b2209422eb82..014e75cb1efd 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -543,6 +543,10 @@ See [Inferred commitments](/concepts/commitments). // allowInsecureAuth: false, // dangerouslyDisableDeviceAuth: false, }, + terminal: { + enabled: false, + // shell: "/bin/zsh", + }, remote: { url: "ws://127.0.0.1:18789", transport: "ssh", // ssh | direct @@ -610,6 +614,8 @@ See [Inferred commitments](/concepts/commitments). - `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required for public non-loopback browser origins. Private same-origin LAN/Tailnet UI loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback. - `controlUi.chatMessageMaxWidth`: optional max-width for grouped Control UI chat messages. Accepts constrained CSS width values such as `960px`, `82%`, `min(1280px, 82%)`, and `calc(100% - 2rem)`. - `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy. +- `terminal.enabled`: opt in to the admin-scoped operator terminal. Default: `false`. The terminal starts a host PTY in the selected agent workspace, inherits the Gateway process environment, and is refused for agents with `sandbox.mode: "all"`. Enable it only for trusted operator deployments; changing it restarts the Gateway and updates the Control UI content security policy. +- `terminal.shell`: optional shell executable. When unset, OpenClaw uses `$SHELL` on Unix and `%ComSpec%` on Windows. - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts. - `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port. - `remote.sshHostKeyPolicy`: macOS SSH tunnel host-key policy. `strict` is the default and requires an already trusted key. `openssh` is an explicit opt-in to the effective OpenSSH configuration for managed aliases; review matching user and system SSH settings before using it. The macOS app and `configure-remote` reset this policy to `strict` when changing targets unless explicitly opted in again. diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 0ecd9080e0d2..208a17dc38c7 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -379,6 +379,14 @@ enumeration of `src/gateway/server-methods/*.ts`. + + - `terminal.open` starts a host PTY for an explicit `agentId` or the default agent and returns the resolved agent, working directory, shell, and confinement state. + - `terminal.input`, `terminal.resize`, and `terminal.close` operate only on sessions owned by the calling connection. + - `terminal.data` and `terminal.exit` events stream only to the connection that opened the session. + - Every terminal method requires `operator.admin`; `gateway.terminal.enabled` must be explicitly true. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs. + + + - `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice. It includes canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags without returning provider secrets or mutating global config. Current Gateways set `ready` after applying runtime provider selection; clients should treat its absence as unverified for compatibility with older Gateways. - `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`). diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 43d414b48b37..15e3ab03047c 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -207,6 +207,16 @@ The Activity tab is an ephemeral browser-local observer for live tool activity. Activity entries keep only sanitized summaries and redacted, truncated output previews. Tool argument values are not stored in Activity state; the UI shows that arguments are hidden and records only the argument field count. The in-memory list follows the current browser tab, survives navigation within the Control UI, and resets on page reload, session switch, or **Clear**. +## Operator terminal + +The dockable operator terminal is disabled by default. To enable it, set `gateway.terminal.enabled: true` and restart the Gateway. The terminal requires an `operator.admin` connection and opens a host PTY in the active agent workspace. New tabs follow the currently selected chat agent. + + +The terminal is an unconfined host shell and inherits the Gateway process environment. Enable it only for trusted operator deployments. OpenClaw refuses terminal sessions for agents with `sandbox.mode: "all"`; changing an active agent to that mode closes its existing and in-flight terminal sessions. + + +Use **Ctrl + backtick** to toggle the dock. The layout supports bottom and right docking, resizes with the browser viewport, and keeps multiple shell tabs. See [Gateway configuration](/gateway/configuration-reference#gateway) for `gateway.terminal.enabled` and the optional `gateway.terminal.shell` override. + ## Chat behavior diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 4ec6576e4752..5f119826dfef 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -96,7 +96,7 @@ export const FIELD_HELP: Record = { "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.", + "Enables the operator terminal for admin-scope clients when true (default: false). This exposes a browser/mobile shell with the gateway process environment, so enable it only for trusted operator deployments. 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": diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 9708162124fb..d69924add5bf 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -269,7 +269,7 @@ export type GatewayRemoteConfig = { * host terminal is allowed. */ export type GatewayTerminalConfig = { - /** Master switch for the operator terminal. Default: true. */ + /** Master switch for the operator terminal. Default: false. */ enabled?: boolean; /** * Shell executable to launch. When unset the host login shell is used diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 2eb12003389e..87b93694eb99 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -684,6 +684,7 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati function createReloaderHarness( readSnapshot: () => Promise, options: { + initialConfig?: OpenClawConfig; initialCompareConfig?: OpenClawConfig; initialInternalWriteHash?: string | null; promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; @@ -693,6 +694,10 @@ function createReloaderHarness( ) { const watcher = createWatcherMock(); vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never); + const onConfigChange = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}); + const onConfigApplied = vi.fn( + async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}, + ); const onHotReload = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}); const onRestart = vi.fn((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {}); let writeListener: ((event: ConfigWriteNotification) => void) | null = null; @@ -709,8 +714,9 @@ function createReloaderHarness( warn: vi.fn(), error: vi.fn(), }; + const initialConfig = options.initialConfig ?? { gateway: { reload: { debounceMs: 0 } } }; const reloader = startGatewayConfigReloader({ - initialConfig: { gateway: { reload: { debounceMs: 0 } } }, + initialConfig, initialCompareConfig: options.initialCompareConfig, initialInternalWriteHash: options.initialInternalWriteHash, readSnapshot, @@ -718,6 +724,8 @@ function createReloaderHarness( initialPluginInstallRecords: options.initialPluginInstallRecords ?? {}, readPluginInstallRecords: options.readPluginInstallRecords ?? (async () => ({})), subscribeToWrites, + onConfigChange, + onConfigApplied, onHotReload, onRestart, log, @@ -725,6 +733,8 @@ function createReloaderHarness( }); return { watcher, + onConfigChange, + onConfigApplied, onHotReload, onRestart, log, @@ -776,6 +786,119 @@ describe("startGatewayConfigReloader", () => { vi.restoreAllMocks(); }); + it("notifies lifecycle owners for no-op sandbox policy changes", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + agents: { defaults: { sandbox: { mode: "off" } } }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }; + const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "sandbox" })); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onConfigChange).toHaveBeenCalledTimes(1); + expect(harness.onConfigChange.mock.calls[0]?.[0].noopPaths).toContain( + "agents.defaults.sandbox.mode", + ); + expect(harness.onConfigChange.mock.calls[0]?.[1]).toBe(nextConfig); + expect(harness.onConfigApplied).toHaveBeenCalledTimes(1); + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + await harness.reloader.stop(); + }); + + it("notifies lifecycle owners before hot reload and commits after success", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + agents: { defaults: { sandbox: { mode: "off" } } }, + hooks: { enabled: false }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + hooks: { enabled: true }, + }; + const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "hot" })); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onConfigChange.mock.invocationCallOrder[0]).toBeLessThan( + harness.onHotReload.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + expect(harness.onHotReload.mock.invocationCallOrder[0]).toBeLessThan( + harness.onConfigApplied.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + await harness.reloader.stop(); + }); + + it("notifies lifecycle owners before queuing a terminal disable restart", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 }, terminal: { enabled: true } }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 }, terminal: { enabled: false } }, + }; + const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "terminal" })); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + await Promise.resolve(); + + expect(harness.onConfigChange).toHaveBeenCalledTimes(1); + expect(harness.onConfigApplied).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + expect(harness.onConfigChange.mock.invocationCallOrder[0]).toBeLessThan( + harness.onRestart.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + await harness.reloader.stop(); + }); + + it("does not notify lifecycle owners when reload mode ignores the change", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { mode: "off", debounceMs: 0 }, terminal: { enabled: true } }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { mode: "off", debounceMs: 0 }, terminal: { enabled: false } }, + }; + const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "off" })); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onConfigChange).not.toHaveBeenCalled(); + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + await harness.reloader.stop(); + }); + + it("does not notify lifecycle owners when hot mode ignores a restart-only change", async () => { + const initialConfig: OpenClawConfig = { + gateway: { reload: { mode: "hot", debounceMs: 0 }, terminal: { enabled: true } }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { mode: "hot", debounceMs: 0 }, terminal: { enabled: false } }, + }; + const readSnapshot = vi.fn(async () => makeSnapshot({ config: nextConfig, hash: "hot" })); + const harness = createReloaderHarness(readSnapshot, { initialConfig }); + + harness.watcher.emit("change"); + await vi.runAllTimersAsync(); + + expect(harness.onConfigChange).not.toHaveBeenCalled(); + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).not.toHaveBeenCalled(); + await harness.reloader.stop(); + }); + it("retries missing snapshots and reloads once config file reappears", async () => { const readSnapshot = vi .fn<() => Promise>() @@ -1034,15 +1157,17 @@ describe("startGatewayConfigReloader", () => { .fn<() => Promise>() .mockResolvedValueOnce(acceptedSnapshot); const promoteSnapshot = vi.fn(async (_snapshot: ConfigFileSnapshot, _reason: string) => true); - const { watcher, onHotReload, log, reloader } = createReloaderHarness(readSnapshot, { - promoteSnapshot, - }); + const { watcher, onConfigApplied, onHotReload, log, reloader } = createReloaderHarness( + readSnapshot, + { promoteSnapshot }, + ); onHotReload.mockRejectedValueOnce(new Error("reload refused")); watcher.emit("change"); await vi.runAllTimersAsync(); expect(onHotReload).toHaveBeenCalledTimes(1); + expect(onConfigApplied).not.toHaveBeenCalled(); expect(promoteSnapshot).not.toHaveBeenCalled(); expect(log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused"); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 9e16dda124ef..b86f822ce62c 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -115,6 +115,8 @@ export function startGatewayConfigReloader(opts: { initialCompareConfig?: OpenClawConfig; initialInternalWriteHash?: string | null; readSnapshot: () => Promise; + onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; + onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise; onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; @@ -279,25 +281,27 @@ export function startGatewayConfigReloader(opts: { noopPaths: pluginInstallTimestampNoopPaths, forceChangedPaths: pluginInstallWholeRecordPaths, }); - if (isNoopReloadPlan(plan) && !followUp.requiresRestart) { - return; - } if (settings.mode === "off") { opts.log.info("config reload disabled (gateway.reload.mode=off)"); return; } + if (isNoopReloadPlan(plan) && !followUp.requiresRestart) { + await opts.onConfigChange?.(plan, nextConfig); + await opts.onConfigApplied?.(plan, nextConfig); + return; + } if (followUp.requiresRestart) { - queueRestart( - { - ...plan, - restartGateway: true, - restartReasons: [...plan.restartReasons, followUp.reason], - }, - nextConfig, - ); + const restartPlan = { + ...plan, + restartGateway: true, + restartReasons: [...plan.restartReasons, followUp.reason], + }; + await opts.onConfigChange?.(restartPlan, nextConfig); + queueRestart(restartPlan, nextConfig); return; } if (settings.mode === "restart") { + await opts.onConfigChange?.({ ...plan, restartGateway: true }, nextConfig); queueRestart(plan, nextConfig); return; } @@ -310,11 +314,14 @@ export function startGatewayConfigReloader(opts: { ); return; } + await opts.onConfigChange?.(plan, nextConfig); queueRestart(plan, nextConfig); return; } + await opts.onConfigChange?.(plan, nextConfig); await opts.onHotReload(plan, nextConfig); + await opts.onConfigApplied?.(plan, nextConfig); }; const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => { diff --git a/src/gateway/control-ui-contract.ts b/src/gateway/control-ui-contract.ts index 4e5a0cd62e10..9de6ae5442c7 100644 --- a/src/gateway/control-ui-contract.ts +++ b/src/gateway/control-ui-contract.ts @@ -3,6 +3,9 @@ /** HTTP path for the Control UI bootstrap config payload. */ export const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/control-ui-config.json"; +/** Marks whether the served document CSP permits the terminal WASM runtime. */ +export const CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE = "data-openclaw-terminal-enabled"; + /** Sandbox policy for assistant-provided embed surfaces inside Control UI. */ export type ControlUiEmbedSandboxMode = "strict" | "scripts" | "trusted"; diff --git a/src/gateway/control-ui.auto-root.http.test.ts b/src/gateway/control-ui.auto-root.http.test.ts index be6bbd7e096d..638e32665b62 100644 --- a/src/gateway/control-ui.auto-root.http.test.ts +++ b/src/gateway/control-ui.auto-root.http.test.ts @@ -84,7 +84,9 @@ describe("handleControlUiHttpRequest auto-detected root", () => { expect(handled).toBe(true); expect(res.statusCode).toBe(200); - expect(responseBody(end)).toBe("fallback-hardlink\n"); + expect(responseBody(end)).toBe( + 'fallback-hardlink\n', + ); }); }); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index 55ed0cc19228..515aa38339ae 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -49,6 +49,7 @@ describe("handleControlUiHttpRequest", () => { chatMessageMaxWidth?: string; seamColor?: string; timeFormat?: "auto" | "12" | "24"; + terminalEnabled: boolean; }; } @@ -340,7 +341,7 @@ describe("handleControlUiHttpRequest", () => { it("sets security headers for Control UI responses", async () => { await withControlUiRoot({ fn: async (tmp) => { - const { res, setHeader } = makeMockHttpResponse(); + const { res, end, setHeader } = makeMockHttpResponse(); const handled = await handleControlUiHttpRequest( { url: "/", method: "GET" } as IncomingMessage, res, @@ -350,7 +351,9 @@ describe("handleControlUiHttpRequest", () => { ); expect(handled).toBe(true); expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); - const csp = setHeader.mock.calls.find((call) => call[0] === "Content-Security-Policy")?.[1]; + const csp = setHeader.mock.calls.findLast( + (call) => call[0] === "Content-Security-Policy", + )?.[1]; expect(typeof csp).toBe("string"); expect(String(csp)).toContain("frame-ancestors 'none'"); expect(String(csp)).toContain("script-src 'self'"); @@ -359,6 +362,59 @@ describe("handleControlUiHttpRequest", () => { ); expect(String(csp)).not.toContain("https://*.tweakcn.com"); expect(String(csp)).not.toContain("script-src 'self' 'unsafe-inline'"); + expect(responseBody(end)).toContain('data-openclaw-terminal-enabled="false"'); + }, + }); + }); + + it("marks terminal-enabled documents and allows the terminal WASM runtime", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end, setHeader } = makeMockHttpResponse(); + const handled = await handleControlUiHttpRequest( + { url: "/", method: "GET" } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + config: { gateway: { terminal: { enabled: true } } }, + }, + ); + expect(handled).toBe(true); + const csp = setHeader.mock.calls.findLast( + (call) => call[0] === "Content-Security-Policy", + )?.[1]; + expect(String(csp)).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(responseBody(end)).toContain('data-openclaw-terminal-enabled="true"'); + }, + }); + }); + + it("uses effective terminal availability instead of raw restart-pending config", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const { res, end, setHeader } = makeMockHttpResponse(); + await handleControlUiHttpRequest({ url: "/", method: "GET" } as IncomingMessage, res, { + root: { kind: "resolved", path: tmp }, + config: { gateway: { terminal: { enabled: true } } }, + terminalEnabled: false, + }); + const csp = setHeader.mock.calls.findLast( + (call) => call[0] === "Content-Security-Policy", + )?.[1]; + expect(String(csp)).not.toContain("'wasm-unsafe-eval'"); + expect(responseBody(end)).toContain('data-openclaw-terminal-enabled="false"'); + + const bootstrap = makeMockHttpResponse(); + await handleControlUiHttpRequest( + { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, + bootstrap.res, + { + root: { kind: "resolved", path: tmp }, + config: { gateway: { terminal: { enabled: false } } }, + terminalEnabled: true, + }, + ); + expect(parseBootstrapPayload(bootstrap.end).terminalEnabled).toBe(true); }, }); }); @@ -783,7 +839,9 @@ describe("handleControlUiHttpRequest", () => { }, ); expect(handled).toBe(true); - expect(end).toHaveBeenCalledWith(html); + expect(end).toHaveBeenCalledWith( + html.replace(" { expect(parsed.chatMessageMaxWidth).toBe("min(1280px, 82%)"); expect(parsed.seamColor).toBe("#1A2b3C"); expect(parsed.timeFormat).toBe("24"); + expect(parsed.terminalEnabled).toBe(false); expect(Array.isArray(parsed.localMediaPreviewRoots)).toBe(true); }, }); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 530c68135c41..0cd64e79f695 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -36,6 +36,7 @@ import { import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, type ControlUiBootstrapConfig, } from "./control-ui-contract.js"; import { buildControlUiCspHeader, computeInlineScriptHashes } from "./control-ui-csp.js"; @@ -74,6 +75,7 @@ const controlUiAssistantMediaTicketSecret = randomBytes(32); type ControlUiRequestOptions = { basePath?: string; config?: OpenClawConfig; + terminalEnabled?: boolean; agentId?: string; root?: ControlUiRootState; auth?: ResolvedGatewayAuth; @@ -764,7 +766,13 @@ function serveResolvedIndexHtml( basePath?: string, allowWasm?: boolean, ) { - const prepared = rewriteControlUiIndexHtmlPublicAssetHrefs(body, basePath ?? ""); + const withBasePath = rewriteControlUiIndexHtmlPublicAssetHrefs(body, basePath ?? ""); + // Let the app initialize fail-closed without guessing whether this document + // was served with the terminal's WASM CSP allowance. + const prepared = withBasePath.replace( + / ({ ok: false, block: { kind: "disabled" } }), + isTerminalEnabled: () => false, loadGatewayModelCatalog: async () => loadManifestModelCatalog({ config: params.getRuntimeConfig() }), getHealthCache: () => null, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 7e4dcc23c372..fede151bac43 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -452,6 +452,7 @@ export function createGatewayHttpServer(opts: { rateLimiter?: AuthRateLimiter; getReadiness?: ReadinessChecker; getRuntimeConfig?: () => OpenClawConfig; + isTerminalEnabled?: () => boolean; tlsOptions?: TlsOptions; }): HttpServer { const { @@ -747,6 +748,8 @@ export function createGatewayHttpServer(opts: { (await getControlUiModule()).handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, config: configSnapshot, + terminalEnabled: + opts.isTerminalEnabled?.() ?? configSnapshot.gateway?.terminal?.enabled === true, agentId: resolveAssistantIdentity({ cfg: configSnapshot }).agentId, root: controlUiRoot, auth: resolvedAuthValue, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 75c980bfbe6e..4124fb39bf36 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -29,6 +29,7 @@ import type { } from "../server-chat-state.js"; import type { DedupeEntry } from "../server-shared.js"; import type { GatewayEventLoopHealth } from "../server/event-loop-health.js"; +import type { TerminalLaunchResolution } from "../terminal/launch.js"; import type { TerminalSessionManager } from "../terminal/session-manager.js"; /** @@ -68,6 +69,8 @@ export type GatewayRequestContext = { cron: CronServiceContract; cronStorePath: string; getRuntimeConfig: () => OpenClawConfig; + resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution; + isTerminalEnabled: () => boolean; execApprovalManager?: ExecApprovalManager; pluginApprovalManager?: ExecApprovalManager; loadGatewayModelCatalog: (params?: { readOnly?: boolean }) => Promise; diff --git a/src/gateway/server-methods/terminal.test.ts b/src/gateway/server-methods/terminal.test.ts index 2909f782b570..906d56c527c0 100644 --- a/src/gateway/server-methods/terminal.test.ts +++ b/src/gateway/server-methods/terminal.test.ts @@ -1,16 +1,32 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveTerminalLaunch } from "../terminal/launch.js"; import { terminalHandlers } from "./terminal.js"; -function makeOpts(params: unknown, terminalConfig: { enabled?: boolean } | undefined) { +function makeOpts( + params: unknown, + terminalConfig: { enabled?: boolean } | undefined, + terminalPolicyConfig?: OpenClawConfig, +) { const sessions = { + open: vi.fn(), write: vi.fn(() => true), resize: vi.fn(() => true), close: vi.fn(() => true), }; const respond = vi.fn(); + const runtimeConfig = { gateway: { terminal: terminalConfig } } as OpenClawConfig; + const policyConfig = terminalPolicyConfig ?? runtimeConfig; const context = { - getRuntimeConfig: () => ({ gateway: { terminal: terminalConfig } }) as OpenClawConfig, + getRuntimeConfig: () => runtimeConfig, + resolveTerminalLaunchPolicy: (agentId?: string) => + resolveTerminalLaunch({ + config: policyConfig, + enabled: policyConfig.gateway?.terminal?.enabled === true, + agentId, + configuredShell: policyConfig.gateway?.terminal?.shell, + }), + isTerminalEnabled: () => policyConfig.gateway?.terminal?.enabled === true, terminalSessions: sessions, // Only the fields the terminal handlers touch are needed here. } as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]["context"]; @@ -23,6 +39,37 @@ function makeOpts(params: unknown, terminalConfig: { enabled?: boolean } | undef return { opts, sessions, respond }; } +describe("terminal.open policy snapshot", () => { + it("rejects reopening after an accepted disable while runtime restart is pending", async () => { + const { opts, sessions, respond } = makeOpts( + { cols: 80, rows: 24 }, + { enabled: true }, + { gateway: { terminal: { enabled: false } } }, + ); + + await terminalHandlers["terminal.open"](opts); + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + }); + + it("rejects reopening after an accepted sandbox tightening", async () => { + const { opts, sessions, respond } = makeOpts( + { cols: 80, rows: 24 }, + { enabled: true }, + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + ); + + await terminalHandlers["terminal.open"](opts); + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + }); +}); + describe("terminal.input kill switch", () => { it("writes to the session when the terminal is enabled", async () => { const { opts, sessions, respond } = makeOpts( @@ -45,6 +92,14 @@ describe("terminal.input kill switch", () => { expect(sessions.close).toHaveBeenCalledWith("conn-1", "s1"); expect(respond).toHaveBeenCalledWith(true, { ok: false }); }); + + it("defaults to disabled when terminal config is absent", async () => { + const { opts, sessions, respond } = makeOpts({ sessionId: "s1", data: "ls\n" }, undefined); + await terminalHandlers["terminal.input"](opts); + expect(sessions.write).not.toHaveBeenCalled(); + expect(sessions.close).toHaveBeenCalledWith("conn-1", "s1"); + expect(respond).toHaveBeenCalledWith(true, { ok: false }); + }); }); describe("terminal.resize kill switch", () => { diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index c2899f538e4d..c9af19c2f6be 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -11,7 +11,7 @@ import { validateTerminalOpenParams, validateTerminalResizeParams, } from "../../../packages/gateway-protocol/src/index.js"; -import { buildTerminalEnv, resolveTerminalLaunch } from "../terminal/launch.js"; +import { buildTerminalEnv } from "../terminal/launch.js"; import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; function invalid(respond: GatewayRequestHandlerOptions["respond"], detail: string): void { @@ -28,7 +28,7 @@ function requireConnId(opts: GatewayRequestHandlerOptions): string | null { } function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): boolean { - return context.getRuntimeConfig().gateway?.terminal?.enabled ?? true; + return context.isTerminalEnabled(); } /** Handlers for the operator terminal method family. */ @@ -51,17 +51,8 @@ export const terminalHandlers: GatewayRequestHandlers = { 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, - }); + const launch = context.resolveTerminalLaunchPolicy(p.agentId); if (!launch.ok) { if (launch.block.kind === "disabled") { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index ea324f1c1d4d..d27d4a5f76e9 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -1348,6 +1348,8 @@ describe("gateway Gmail hot reload handlers", () => { resolveSharedGatewaySessionGenerationForConfig: () => undefined, sharedGatewaySessionGenerationState: { current: undefined, required: null }, clients: [], + reconcileTerminalSessions: vi.fn(), + commitTerminalConfig: vi.fn(), }); const registeredWriteListener = writeListenerRef.current; if (!registeredWriteListener) { @@ -1458,6 +1460,8 @@ describe("gateway Gmail hot reload handlers", () => { resolveSharedGatewaySessionGenerationForConfig: () => undefined, sharedGatewaySessionGenerationState: { current: undefined, required: null }, clients: [], + reconcileTerminalSessions: vi.fn(), + commitTerminalConfig: vi.fn(), }); const registeredWriteListener = writeListenerRef.current; if (!registeredWriteListener) { @@ -1567,6 +1571,8 @@ describe("gateway Gmail hot reload handlers", () => { resolveSharedGatewaySessionGenerationForConfig: () => undefined, sharedGatewaySessionGenerationState: { current: undefined, required: null }, clients: [], + reconcileTerminalSessions: vi.fn(), + commitTerminalConfig: vi.fn(), }); const registeredWriteListener = writeListenerRef.current; if (!registeredWriteListener) { diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 47b55fcfaeea..351b2fd5adb6 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -222,6 +222,8 @@ type ManagedGatewayConfigReloaderParams = Omit< resolveSharedGatewaySessionGenerationForConfig: (config: OpenClawConfig) => string | undefined; sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState; clients: Iterable; + reconcileTerminalSessions: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void; + commitTerminalConfig: () => void; }; export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) { @@ -736,6 +738,8 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe readSnapshot: params.readSnapshot, promoteSnapshot: async (snapshot, _reason) => await params.promoteSnapshot(snapshot), subscribeToWrites: params.subscribeToWrites, + onConfigChange: (plan, nextConfig) => params.reconcileTerminalSessions(plan, nextConfig), + onConfigApplied: () => params.commitTerminalConfig(), onHotReload: async (plan, nextConfig) => { const previousSharedGatewaySessionGeneration = params.sharedGatewaySessionGenerationState.current; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 49a2fd2cf108..06068a454a44 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -22,6 +22,11 @@ function makeContextParams( deps: {} as never, runtimeState, getRuntimeConfig: vi.fn(() => ({}) as never), + resolveTerminalLaunchPolicy: vi.fn(() => ({ + ok: false as const, + block: { kind: "disabled" as const }, + })), + isTerminalEnabled: vi.fn(() => false), execApprovalManager: undefined, pluginApprovalManager: undefined, loadGatewayModelCatalog: vi.fn(async () => []), diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index e79a27e38fed..8313e2d1fefd 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -16,6 +16,8 @@ export type GatewayRequestContextParams = { deps: GatewayRequestContext["deps"]; runtimeState: Pick; getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"]; + resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"]; + isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"]; execApprovalManager: GatewayRequestContext["execApprovalManager"]; pluginApprovalManager: GatewayRequestContext["pluginApprovalManager"]; loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"]; @@ -90,6 +92,8 @@ export function createGatewayRequestContext( return params.runtimeState.cronState.storePath; }, getRuntimeConfig: params.getRuntimeConfig, + resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy, + isTerminalEnabled: params.isTerminalEnabled, execApprovalManager: params.execApprovalManager, pluginApprovalManager: params.pluginApprovalManager, loadGatewayModelCatalog: params.loadGatewayModelCatalog, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 072d4fc82cab..4875f99dd99d 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -100,6 +100,7 @@ export async function createGatewayRuntimeState(params: { logHooks: ReturnType; logPlugins: ReturnType; getReadiness?: ReadinessChecker; + isTerminalEnabled: () => boolean; }): Promise<{ releasePluginRouteRegistry: () => void; httpServer: HttpServer; @@ -267,6 +268,7 @@ export async function createGatewayRuntimeState(params: { getResolvedAuth: params.getResolvedAuth, rateLimiter: params.rateLimiter, getReadiness: params.getReadiness, + isTerminalEnabled: params.isTerminalEnabled, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, }); // Attach upgrade handler BEFORE listening to prevent race condition diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a6e09101d516..a0da20bad1c1 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -823,6 +823,8 @@ export async function startGatewayServer( log, }), ); + const { createTerminalLaunchPolicy } = await import("./terminal/launch.js"); + const terminalLaunchPolicy = createTerminalLaunchPolicy(cfgAtStart); const wizardRunner = opts.wizardRunner ?? runDefaultSetupWizard; const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker(); @@ -910,6 +912,7 @@ export async function startGatewayServer( strictTransportSecurityHeader, resolvedAuth, rateLimiter: authRateLimiter, + isTerminalEnabled: terminalLaunchPolicy.isEnabled, gatewayTls, getResolvedAuth, hooksConfig: () => runtimeState?.hooksConfig ?? initialHooksConfig, @@ -1424,6 +1427,8 @@ export async function startGatewayServer( deps, runtimeState, getRuntimeConfig, + resolveTerminalLaunchPolicy: terminalLaunchPolicy.resolve, + isTerminalEnabled: terminalLaunchPolicy.isEnabled, execApprovalManager, pluginApprovalManager, loadGatewayModelCatalog, @@ -1756,6 +1761,13 @@ export async function startGatewayServer( onCronRestart: () => { gatewayCronStartHandled = true; }, + reconcileTerminalSessions: (plan, nextConfig) => { + terminalLaunchPolicy.prepareConfig(nextConfig, { restartPending: plan.restartGateway }); + terminalSessions.closeDisallowedAgents( + (agentId) => terminalLaunchPolicy.resolve(agentId).ok, + ); + }, + commitTerminalConfig: terminalLaunchPolicy.commitConfig, channelManager, activateRuntimeSecrets, resolveSharedGatewaySessionGenerationForConfig, diff --git a/src/gateway/terminal/launch.test.ts b/src/gateway/terminal/launch.test.ts index 817e405f0f28..b3b49caa90c5 100644 --- a/src/gateway/terminal/launch.test.ts +++ b/src/gateway/terminal/launch.test.ts @@ -1,9 +1,14 @@ -import { mkdtempSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { buildTerminalEnv, resolveTerminalLaunch, resolveTerminalShell } from "./launch.js"; +import { + buildTerminalEnv, + createTerminalLaunchPolicy, + resolveTerminalLaunch, + resolveTerminalShell, +} from "./launch.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("resolveTerminalShell", () => { it("prefers an explicitly configured shell", () => { @@ -41,7 +46,7 @@ describe("resolveTerminalLaunch", () => { }); it("returns a host plan starting in the agent workspace", () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), "term-ws-")); + const workspace = tempDirs.make("term-ws-"); const config = { agents: { defaults: { workspace } }, } as unknown as OpenClawConfig; @@ -75,7 +80,7 @@ describe("resolveTerminalLaunch", () => { }); 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 workspace = tempDirs.make("term-ws-nm-"); const config = { agents: { defaults: { workspace, sandbox: { mode: "non-main" } } }, } as unknown as OpenClawConfig; @@ -110,7 +115,7 @@ describe("resolveTerminalLaunch", () => { }); it("accepts an explicit id that names a configured agent", () => { - const workspace = mkdtempSync(path.join(os.tmpdir(), "term-ws-id-")); + const workspace = tempDirs.make("term-ws-id-"); const config = { agents: { defaults: { workspace }, @@ -131,6 +136,198 @@ describe("resolveTerminalLaunch", () => { }); }); +describe("createTerminalLaunchPolicy", () => { + it("applies restart-bound revocations without granting access early", () => { + const enabled = { + gateway: { terminal: { enabled: true } }, + } as OpenClawConfig; + const policy = createTerminalLaunchPolicy(enabled); + + policy.prepareConfig({}, { restartPending: true }); + policy.prepareConfig(enabled, { restartPending: true }); + expect(policy.isEnabled()).toBe(false); + expect(policy.resolve()).toEqual({ ok: false, block: { kind: "disabled" } }); + + const disabledPolicy = createTerminalLaunchPolicy({}); + disabledPolicy.prepareConfig(enabled, { restartPending: true }); + expect(disabledPolicy.isEnabled()).toBe(false); + expect(disabledPolicy.resolve()).toEqual({ ok: false, block: { kind: "disabled" } }); + }); + + it("preserves sandbox revocations across later restart-bound updates", () => { + const workspace = tempDirs.make("term-policy-agent-"); + const baseConfig: OpenClawConfig = { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { workspace }, list: [{ id: "ops" }] }, + }; + const policy = createTerminalLaunchPolicy(baseConfig); + policy.prepareConfig( + { + ...baseConfig, + agents: { + defaults: { workspace }, + list: [{ id: "ops", sandbox: { mode: "all" } }], + }, + }, + { restartPending: true }, + ); + policy.prepareConfig(baseConfig, { restartPending: true }); + + const resolved = policy.resolve("ops"); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.block.kind).toBe("sandboxed"); + } + }); + + it("keeps current launch details until a restart-bound change takes effect", () => { + const workspace = tempDirs.make("term-policy-"); + const policy = createTerminalLaunchPolicy({ + gateway: { terminal: { enabled: true, shell: "/bin/old-shell" } }, + agents: { defaults: { workspace } }, + }); + + policy.prepareConfig( + { + gateway: { terminal: { enabled: true, shell: "/bin/new-shell" } }, + agents: { defaults: { workspace } }, + }, + { restartPending: true }, + ); + + const resolved = policy.resolve(); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.plan.shell).toBe("/bin/old-shell"); + } + + policy.prepareConfig( + { + gateway: { terminal: { enabled: true, shell: "/bin/new-shell" } }, + agents: { defaults: { workspace, sandbox: { mode: "all" } } }, + }, + { restartPending: false }, + ); + const tightened = policy.resolve(); + expect(tightened.ok).toBe(false); + if (!tightened.ok) { + expect(tightened.block.kind).toBe("sandboxed"); + } + }); + + it("applies non-restart sandbox policy changes immediately", () => { + const policy = createTerminalLaunchPolicy({ + gateway: { terminal: { enabled: true } }, + }); + policy.prepareConfig( + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + { restartPending: false }, + ); + + const blocked = policy.resolve(); + expect(blocked.ok).toBe(false); + if (!blocked.ok) { + expect(blocked.block.kind).toBe("sandboxed"); + } + }); + + it("does not grant a non-restart policy relaxation before commit", () => { + const policy = createTerminalLaunchPolicy({ + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }); + policy.prepareConfig( + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "off" } } }, + }, + { restartPending: false }, + ); + expect(policy.resolve().ok).toBe(false); + + policy.commitConfig(); + expect(policy.resolve().ok).toBe(true); + }); + + it("retains failed hot-reload revocations until a later commit succeeds", () => { + const baseConfig: OpenClawConfig = { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "off" } } }, + }; + const policy = createTerminalLaunchPolicy(baseConfig); + policy.prepareConfig( + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + { restartPending: false }, + ); + // Simulate a failed hot reload, followed by a relaxation that has not + // succeeded yet. The first attempt's revocation must remain in force. + policy.prepareConfig(baseConfig, { restartPending: false }); + expect(policy.resolve().ok).toBe(false); + + policy.commitConfig(); + expect(policy.resolve().ok).toBe(true); + + const restartPolicy = createTerminalLaunchPolicy(baseConfig); + restartPolicy.prepareConfig( + { + ...baseConfig, + agents: { defaults: { sandbox: { mode: "all" } } }, + }, + { restartPending: false }, + ); + restartPolicy.prepareConfig(baseConfig, { restartPending: true }); + expect(restartPolicy.resolve().ok).toBe(false); + }); + + it("does not promote a terminal setting previously ignored by reload mode", () => { + const disabledPolicy = createTerminalLaunchPolicy({}); + disabledPolicy.prepareConfig( + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "non-main" } } }, + }, + { restartPending: false }, + ); + disabledPolicy.commitConfig(); + expect(disabledPolicy.isEnabled()).toBe(false); + expect(disabledPolicy.resolve()).toEqual({ ok: false, block: { kind: "disabled" } }); + + const enabledPolicy = createTerminalLaunchPolicy({ + gateway: { terminal: { enabled: true, shell: "/bin/current-shell" } }, + }); + enabledPolicy.prepareConfig( + { + gateway: { terminal: { enabled: false, shell: "/bin/ignored-shell" } }, + agents: { defaults: { sandbox: { mode: "non-main" } } }, + }, + { restartPending: false }, + ); + enabledPolicy.commitConfig(); + expect(enabledPolicy.isEnabled()).toBe(true); + const resolved = enabledPolicy.resolve(); + expect(resolved.ok).toBe(true); + if (resolved.ok) { + expect(resolved.plan.shell).toBe("/bin/current-shell"); + } + + enabledPolicy.prepareConfig({}, { restartPending: true }); + enabledPolicy.prepareConfig( + { + gateway: { terminal: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "non-main" } } }, + }, + { restartPending: false }, + ); + expect(enabledPolicy.isEnabled()).toBe(false); + }); +}); + describe("buildTerminalEnv", () => { it("carries the base env, defaults TERM, and marks the terminal", () => { const env = buildTerminalEnv({ PATH: "/usr/bin", FOO: "bar" }); diff --git a/src/gateway/terminal/launch.ts b/src/gateway/terminal/launch.ts index f8673ebf99ee..07e2e9c470a2 100644 --- a/src/gateway/terminal/launch.ts +++ b/src/gateway/terminal/launch.ts @@ -31,6 +31,13 @@ export type TerminalLaunchResolution = | { ok: true; plan: TerminalLaunchPlan } | { ok: false; block: TerminalLaunchBlock }; +export type TerminalLaunchPolicy = { + resolve: (agentId?: string) => TerminalLaunchResolution; + isEnabled: () => boolean; + prepareConfig: (config: OpenClawConfig, options: { restartPending: boolean }) => void; + commitConfig: () => void; +}; + /** Picks the interactive shell: explicit config, then the host login shell. */ export function resolveTerminalShell(params: { configuredShell?: string; @@ -103,6 +110,132 @@ export function resolveTerminalLaunch(params: { return { ok: true, plan: { agentId, cwd, shell, args } }; } +/** Maintains fail-closed terminal admission across deferred config restarts. */ +export function createTerminalLaunchPolicy(initialConfig: OpenClawConfig): TerminalLaunchPolicy { + let activeConfig = initialConfig; + let hasPendingRestart = false; + let terminalDisabledUntilRestart = false; + let preparedConfig: OpenClawConfig | null = null; + let terminalDisabledUntilCommit = false; + const blockedAgentsUntilRestart = new Map(); + const blockedAgentsUntilCommit = new Map(); + const preserveTerminalConfig = (config: OpenClawConfig, owner: OpenClawConfig) => { + const { terminal: _ignored, ...gateway } = config.gateway ?? {}; + const terminal = owner.gateway?.terminal; + return { + ...config, + gateway: { + ...gateway, + ...(terminal === undefined ? {} : { terminal }), + }, + }; + }; + const resolveForConfig = (config: OpenClawConfig, agentId?: string) => { + const terminalConfig = config.gateway?.terminal; + return resolveTerminalLaunch({ + config, + enabled: terminalConfig?.enabled === true, + agentId, + configuredShell: terminalConfig?.shell, + }); + }; + const accumulateRestartRestrictions = (config: OpenClawConfig) => { + if (config.gateway?.terminal?.enabled !== true) { + terminalDisabledUntilRestart = true; + return; + } + const activeAgentIds = new Set([ + ...listAgentIds(activeConfig), + resolveDefaultAgentId(activeConfig), + ]); + for (const agentId of activeAgentIds) { + const candidate = resolveForConfig(config, agentId); + if (!candidate.ok) { + blockedAgentsUntilRestart.set(agentId, candidate.block); + } + } + }; + const accumulateCommitRestrictions = (config: OpenClawConfig) => { + if (config.gateway?.terminal?.enabled !== true) { + terminalDisabledUntilCommit = true; + return; + } + const activeAgentIds = new Set([ + ...listAgentIds(activeConfig), + resolveDefaultAgentId(activeConfig), + ]); + for (const agentId of activeAgentIds) { + const candidate = resolveForConfig(config, agentId); + if (!candidate.ok) { + blockedAgentsUntilCommit.set(agentId, candidate.block); + } + } + }; + + return { + resolve: (agentId) => { + const active = resolveForConfig(activeConfig, agentId); + if (!active.ok) { + return active; + } + if (terminalDisabledUntilRestart) { + return { ok: false, block: { kind: "disabled" } }; + } + const pendingBlock = blockedAgentsUntilRestart.get(active.plan.agentId); + if (pendingBlock) { + return { ok: false, block: pendingBlock }; + } + const preparedBlock = blockedAgentsUntilCommit.get(active.plan.agentId); + if (preparedBlock) { + return { ok: false, block: preparedBlock }; + } + if (preparedConfig) { + const prepared = resolveForConfig(preparedConfig, active.plan.agentId); + if (!prepared.ok) { + return prepared; + } + } + return active; + }, + isEnabled: () => + activeConfig.gateway?.terminal?.enabled === true && + !terminalDisabledUntilRestart && + !terminalDisabledUntilCommit && + (preparedConfig === null || preparedConfig.gateway?.terminal?.enabled === true), + prepareConfig: (config, options) => { + if (options.restartPending) { + hasPendingRestart = true; + terminalDisabledUntilRestart ||= terminalDisabledUntilCommit; + for (const [agentId, block] of blockedAgentsUntilCommit) { + blockedAgentsUntilRestart.set(agentId, block); + } + terminalDisabledUntilCommit = false; + blockedAgentsUntilCommit.clear(); + preparedConfig = null; + accumulateRestartRestrictions(config); + return; + } + // No-op/hot plans may arrive with restart-only terminal fields that an + // earlier reload mode ignored. Advance agent policy, but preserve the + // terminal subtree already owned by the active or pending process. + if (hasPendingRestart) { + accumulateRestartRestrictions(config); + return; + } + preparedConfig = preserveTerminalConfig(config, activeConfig); + accumulateCommitRestrictions(preparedConfig); + }, + commitConfig: () => { + if (preparedConfig && !hasPendingRestart) { + activeConfig = preparedConfig; + } + preparedConfig = null; + terminalDisabledUntilCommit = false; + blockedAgentsUntilCommit.clear(); + }, + }; +} + /** Builds the child environment for a host terminal from the gateway env. */ export function buildTerminalEnv(baseEnv: NodeJS.ProcessEnv): Record { const env: Record = {}; diff --git a/src/gateway/terminal/session-manager.test.ts b/src/gateway/terminal/session-manager.test.ts index a601e490de86..b0405b864a99 100644 --- a/src/gateway/terminal/session-manager.test.ts +++ b/src/gateway/terminal/session-manager.test.ts @@ -143,6 +143,47 @@ describe("TerminalSessionManager", () => { expect(emit).not.toHaveBeenCalled(); }); + it("closes live and pending sessions when their agent becomes disallowed", async () => { + const emit = vi.fn(); + const livePty = makeFakePty(); + const pendingPty = makeFakePty(); + let releasePending: (() => void) | undefined; + const pendingGate = new Promise((resolve) => { + releasePending = resolve; + }); + const manager = new TerminalSessionManager({ + emit, + spawn: async (request) => { + if (request.cwd === "/pending") { + await pendingGate; + return pendingPty; + } + return livePty; + }, + }); + + const live = await manager.open(baseRequest({ agentId: "locked" })); + expect(live.ok).toBe(true); + const pending = manager.open( + baseRequest({ agentId: "locked", connId: "conn-2", cwd: "/pending" }), + ); + + manager.closeDisallowedAgents((agentId) => agentId !== "locked"); + expect(livePty.killed).toBe(true); + expect(manager.size).toBe(0); + expect(emit).toHaveBeenCalledWith( + "conn-1", + TERMINAL_EVENT_EXIT, + expect.objectContaining({ reason: "closed" }), + ); + + releasePending?.(); + const pendingOutcome = await pending; + expect(pendingOutcome.ok).toBe(false); + expect(pendingPty.killed).toBe(true); + expect(manager.size).toBe(0); + }); + it("disposes every session silently (gateway shutdown)", async () => { const emit = vi.fn(); const ptys = [makeFakePty(), makeFakePty()]; diff --git a/src/gateway/terminal/session-manager.ts b/src/gateway/terminal/session-manager.ts index 8c3f2d561dbc..313c56e6082b 100644 --- a/src/gateway/terminal/session-manager.ts +++ b/src/gateway/terminal/session-manager.ts @@ -51,8 +51,8 @@ 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 }; +/** Abort state shared between a pending open and lifecycle/policy teardown. */ +type OpenToken = { agentId: string; abortMessage?: string }; /** * Tracks live PTY sessions keyed by session id, with a reverse index by @@ -94,7 +94,7 @@ export class TerminalSessionManager { } // Reserve the slot before the async spawn so it is visible to concurrent opens. this.opening += 1; - const token: OpenToken = { aborted: false }; + const token: OpenToken = { agentId: request.agentId }; this.trackPendingOpen(request.connId, token); let pty: TerminalPtyHandle; try { @@ -116,7 +116,7 @@ export class TerminalSessionManager { // await — so the counts never both drop). this.opening -= 1; this.untrackPendingOpen(request.connId, token); - if (token.aborted) { + if (token.abortMessage) { // The owning connection disconnected while the shell was spawning; kill it // now rather than register an orphan no one can reach or close. try { @@ -124,7 +124,7 @@ export class TerminalSessionManager { } catch { // Best-effort; the process may already be gone. } - return { ok: false, code: "closed", message: "connection closed during open" }; + return { ok: false, code: "closed", message: token.abortMessage }; } const session: TerminalSession = { @@ -233,7 +233,7 @@ export class TerminalSessionManager { const opens = this.pendingOpens.get(connId); if (opens) { for (const token of opens) { - token.aborted = true; + token.abortMessage = "connection closed during open"; } } const ids = this.byConn.get(connId); @@ -250,6 +250,27 @@ export class TerminalSessionManager { this.byConn.delete(connId); } + /** Closes live and pending sessions whose agent no longer permits a host shell. */ + closeDisallowedAgents(isAllowed: (agentId: string) => boolean): void { + // Config can change while spawn is awaiting the native PTY import. Mark the + // pending open so it kills the process instead of registering stale access. + for (const opens of this.pendingOpens.values()) { + for (const token of opens) { + if (!isAllowed(token.agentId)) { + token.abortMessage = "terminal closed because the agent policy changed"; + } + } + } + // Snapshot first: finalize() mutates the session map. + for (const session of Array.from(this.sessions.values())) { + if (!isAllowed(session.agentId)) { + this.finalize(session, "closed", { + error: "terminal closed because the agent policy changed", + }); + } + } + } + /** Kills every session; used on gateway shutdown. */ /** * Tears down every session on gateway shutdown/stop. Silent because the @@ -260,7 +281,7 @@ export class TerminalSessionManager { // 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; + token.abortMessage = "gateway closed during terminal open"; } } // Snapshot first: finalize() deletes from this.sessions during iteration. diff --git a/src/gateway/test-helpers.server-runtime-state.ts b/src/gateway/test-helpers.server-runtime-state.ts index 628d1d9c7906..194392baafe3 100644 --- a/src/gateway/test-helpers.server-runtime-state.ts +++ b/src/gateway/test-helpers.server-runtime-state.ts @@ -22,6 +22,7 @@ export async function createGatewayRuntimeStateForTest( openResponsesEnabled: false, resolvedAuth: {} as never, getResolvedAuth: () => ({}) as never, + isTerminalEnabled: () => false, hooksConfig: () => null, getHookClientIpConfig: () => ({}) as never, pluginRegistry, diff --git a/src/scripts/test-projects.test.ts b/src/scripts/test-projects.test.ts index b8e7c725d8ae..1e7ff6ce4ba6 100644 --- a/src/scripts/test-projects.test.ts +++ b/src/scripts/test-projects.test.ts @@ -941,6 +941,7 @@ describe("test-projects args", () => { "src/gateway/server.agent.gateway-server-agent-b.test.ts", "src/gateway/server.chat.gateway-server-chat-b.test.ts", "src/gateway/server.sessions.permissions-hooks.test.ts", + "src/gateway/terminal/launch.test.ts", ], watchMode: false, }, diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5f4d446d801a..ca08a1ee4260 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -4274,6 +4274,7 @@ export function renderApp(state: AppViewState) { : "dark"; return html``; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 8dda0cba009b..d89df99d2de5 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -1,6 +1,7 @@ -// Control UI module implements app behavior. import { LitElement } from "lit"; import { state } from "lit/decorators.js"; +// Control UI module implements app behavior. +import { CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE } from "../../../src/gateway/control-ui-contract.js"; import { i18n, I18nController, isSupportedLocale, t } from "../i18n/index.ts"; import type { ActivityEntry, ActivityStatus } from "./activity-model.ts"; import { @@ -175,6 +176,9 @@ declare global { const bootAssistantIdentity = normalizeAssistantIdentity({}); const bootLocalUserIdentity = loadLocalUserIdentity(); +const bootTerminalEnabled = + typeof document !== "undefined" && + document.documentElement.getAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE) === "true"; const FULL_MESSAGE_SIDEBAR_MAX_CHARS = 500_000; function isSidebarMarkdownLike(content: SidebarContent | null): content is SidebarContent { @@ -255,7 +259,7 @@ export class OpenClawApp extends LitElement { @state() userAvatar = bootLocalUserIdentity.avatar; @state() localMediaPreviewRoots: string[] = []; @state() embedSandboxMode: "strict" | "scripts" | "trusted" = "strict"; - @state() terminalEnabled = true; + @state() terminalEnabled = bootTerminalEnabled; @state() allowExternalEmbedUrls = false; @state() chatMessageMaxWidth: string | null = null; @state() serverVersion: string | null = null; diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts index e9525edc5c3c..c241261b033f 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.test.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -1,7 +1,10 @@ /* @vitest-environment jsdom */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH } from "../../../../src/gateway/control-ui-contract.js"; +import { + CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, +} from "../../../../src/gateway/control-ui-contract.js"; import { resolveUiHourCycleOptions, setUiTimeFormatPreference } from "../format.ts"; import { loadControlUiBootstrapConfig } from "./control-ui-bootstrap.ts"; @@ -17,6 +20,7 @@ describe("loadControlUiBootstrapConfig", () => { afterEach(() => { setUiTimeFormatPreference("auto"); document.documentElement.removeAttribute("style"); + document.documentElement.removeAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE); }); it("threads agents.defaults.timeFormat into the UI hour-cycle preference", async () => { @@ -248,6 +252,7 @@ describe("loadControlUiBootstrapConfig", () => { }); it("reloads the document when the terminal flips from disabled to enabled", async () => { + document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "false"); const fetchMock = vi .fn() .mockResolvedValueOnce({ @@ -291,6 +296,70 @@ describe("loadControlUiBootstrapConfig", () => { vi.unstubAllGlobals(); }); + it("enables the terminal without reloading when the document CSP already allows it", async () => { + document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "true"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ basePath: "", terminalEnabled: true }), + }) as unknown as typeof fetch, + ); + const reload = vi.fn(); + vi.stubGlobal("window", { + location: { origin: "http://localhost", reload }, + } as unknown as Window & typeof globalThis); + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + terminalEnabled: false, + }; + + await loadControlUiBootstrapConfig(state, { applyIdentity: false }); + + expect(state.terminalEnabled).toBe(true); + expect(reload).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); + + it("reloads the document when disabling removes the terminal CSP allowance", async () => { + document.documentElement.setAttribute(CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, "true"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ basePath: "", terminalEnabled: false }), + }) as unknown as typeof fetch, + ); + const reload = vi.fn(); + vi.stubGlobal("window", { + location: { origin: "http://localhost", reload }, + } as unknown as Window & typeof globalThis); + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + localMediaPreviewRoots: [], + embedSandboxMode: "scripts" as const, + allowExternalEmbedUrls: false, + serverVersion: null, + terminalEnabled: true, + }; + + await loadControlUiBootstrapConfig(state, { applyIdentity: false }); + + expect(reload).toHaveBeenCalledTimes(1); + expect(state.terminalEnabled).toBe(true); + vi.unstubAllGlobals(); + }); + it("does not apply default-agent bootstrap identity to an active non-default session", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts index 82b055172446..13af3487321d 100644 --- a/ui/src/ui/controllers/control-ui-bootstrap.ts +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -1,6 +1,7 @@ // Control UI controller manages control ui bootstrap gateway state. import { CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, type ControlUiBootstrapConfig, type ControlUiEmbedSandboxMode, } from "../../../../src/gateway/control-ui-contract.js"; @@ -189,16 +190,17 @@ export async function loadControlUiBootstrapConfig( typeof parsed.chatMessageMaxWidth === "string" && parsed.chatMessageMaxWidth.trim() ? parsed.chatMessageMaxWidth : null; - // Default true when older gateways omit the flag; only an explicit false hides it. - const terminalEnabled = parsed.terminalEnabled !== false; - if (terminalEnabled && state.terminalEnabled === false) { - // Only a refetch (reconnect after the enabling gateway restart) can flip - // an explicit false to true, which means the document was served while - // the terminal was disabled and its CSP lacks ghostty-web's WASM - // allowances. Headers cannot change on a live document, so reload once - // to pick up the relaxed policy; the enabled->disabled direction just - // hides the panel and needs no reload. Loop-safe: after the reload the - // flag starts true, so this branch cannot fire again. + // The host shell is opt-in; absent flags from older gateways stay disabled. + const terminalEnabled = parsed.terminalEnabled === true; + const documentTerminalState = document.documentElement.getAttribute( + CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE, + ); + const documentTerminalEnabled = + documentTerminalState === "true" ? true : documentTerminalState === "false" ? false : null; + if (documentTerminalEnabled !== null && terminalEnabled !== documentTerminalEnabled) { + // CSP headers cannot change on a live document. Reload in either + // direction so enable gains the WASM allowance and disable removes it. + // Loop-safe: the replacement document carries the accepted state. window.location.reload(); return; } diff --git a/ui/src/ui/terminal/terminal-connection.test.ts b/ui/src/ui/terminal/terminal-connection.test.ts index fdf67d01689b..d48ec50e1a58 100644 --- a/ui/src/ui/terminal/terminal-connection.test.ts +++ b/ui/src/ui/terminal/terminal-connection.test.ts @@ -55,6 +55,20 @@ describe("TerminalConnection", () => { expect(data).toEqual(["hello", "!"]); }); + it("forwards the selected agent when opening a session", async () => { + const client = makeFakeClient(); + const conn = new TerminalConnection(client); + await conn.open( + { agentId: "ops", cols: 100, rows: 30 }, + { onData: () => {}, onExit: () => {} }, + ); + + expect(client.requests[0]).toEqual({ + method: "terminal.open", + params: { agentId: "ops", cols: 100, rows: 30 }, + }); + }); + it("does not deliver data to the wrong session", async () => { const client = makeFakeClient(); const conn = new TerminalConnection(client); diff --git a/ui/src/ui/terminal/terminal-panel.test.ts b/ui/src/ui/terminal/terminal-panel.test.ts new file mode 100644 index 000000000000..f887d064dfdc --- /dev/null +++ b/ui/src/ui/terminal/terminal-panel.test.ts @@ -0,0 +1,67 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TerminalGatewayClient } from "./terminal-connection.ts"; + +vi.mock("ghostty-web", () => { + class Terminal { + cols = 100; + rows = 30; + viewportY = 0; + + loadAddon() {} + open() {} + onData() {} + onResize() {} + write() {} + focus() {} + dispose() {} + } + + class FitAddon { + fit() {} + observeResize() {} + dispose() {} + } + + return { init: vi.fn(async () => {}), Terminal, FitAddon }; +}); + +import { OpenClawTerminalPanel } from "./terminal-panel.ts"; + +describe("OpenClawTerminalPanel", () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it("opens new sessions for the selected agent", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const client: TerminalGatewayClient = { + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + return { + sessionId: "session-1", + agentId: "ops", + shell: "/bin/zsh", + cwd: "/work/ops", + confined: false, + } as T; + }, + addEventListener: () => () => {}, + }; + const panel = new OpenClawTerminalPanel(); + panel.client = client; + panel.agentId = "ops"; + panel.available = true; + document.body.append(panel); + + panel.toggle(); + + await vi.waitFor(() => { + expect(requests[0]).toEqual({ + method: "terminal.open", + params: { agentId: "ops", cols: 100, rows: 30 }, + }); + }); + }); +}); diff --git a/ui/src/ui/terminal/terminal-panel.ts b/ui/src/ui/terminal/terminal-panel.ts index 0183506d3ccd..f35f2125fed7 100644 --- a/ui/src/ui/terminal/terminal-panel.ts +++ b/ui/src/ui/terminal/terminal-panel.ts @@ -99,6 +99,8 @@ function clampSize(value: unknown, min: number, max: number, fallback: number): export class OpenClawTerminalPanel extends LitElement { /** Gateway client used for terminal.* RPCs; null until connected. */ @property({ attribute: false }) client: TerminalGatewayClient | null = null; + /** Agent whose workspace and sandbox policy own newly opened sessions. */ + @property({ attribute: false }) agentId: string | null = null; /** Whether the connected gateway advertises the terminal surface. */ @property({ type: Boolean }) available = false; /** Active Control UI color mode, mirrored into the terminal theme. */ @@ -261,6 +263,8 @@ export class OpenClawTerminalPanel extends LitElement { } this.booting = true; this.errorText = null; + // Freeze the selection for this tab; later agent changes affect only new tabs. + const agentId = this.agentId?.trim() || undefined; // Tracked outside the try so the catch can dispose a tab whose open failed. let createdTab: TerminalTabState | undefined; try { @@ -315,7 +319,7 @@ export class OpenClawTerminalPanel extends LitElement { const rows = term.rows || 24; const result = await connection.open( - { cols, rows }, + { agentId, cols, rows }, { // The cancelled guard also protects the buffered-event replay inside // connection.open from writing to an already-disposed terminal.