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
This commit is contained in:
Peter Steinberger
2026-07-04 16:34:07 -04:00
committed by GitHub
parent 7a0188cbd2
commit 7e8ea61b08
36 changed files with 940 additions and 68 deletions
+1
View File
@@ -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
+6
View File
@@ -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.
+8
View File
@@ -379,6 +379,14 @@ enumeration of `src/gateway/server-methods/*.ts`.
</Accordion>
<Accordion title="Operator terminal">
- `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.
</Accordion>
<Accordion title="Talk and TTS">
- `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`).
+10
View File
@@ -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.
<Warning>
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.
</Warning>
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
<AccordionGroup>
+1 -1
View File
@@ -96,7 +96,7 @@ export const FIELD_HELP: Record<string, string> = {
"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":
+1 -1
View File
@@ -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
+129 -4
View File
@@ -684,6 +684,7 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
function createReloaderHarness(
readSnapshot: () => Promise<ConfigFileSnapshot>,
options: {
initialConfig?: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -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<ConfigFileSnapshot>>()
@@ -1034,15 +1157,17 @@ describe("startGatewayConfigReloader", () => {
.fn<() => Promise<ConfigFileSnapshot>>()
.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");
+18 -11
View File
@@ -115,6 +115,8 @@ export function startGatewayConfigReloader(opts: {
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onConfigChange?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
onConfigApplied?: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -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) => {
+3
View File
@@ -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";
@@ -84,7 +84,9 @@ describe("handleControlUiHttpRequest auto-detected root", () => {
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(responseBody(end)).toBe("<html>fallback-hardlink</html>\n");
expect(responseBody(end)).toBe(
'<html data-openclaw-terminal-enabled="false">fallback-hardlink</html>\n',
);
});
});
+62 -3
View File
@@ -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("<html", '<html data-openclaw-terminal-enabled="false"'),
);
},
});
});
@@ -840,6 +898,7 @@ describe("handleControlUiHttpRequest", () => {
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);
},
});
+13 -4
View File
@@ -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(
/<html\b/i,
`<html ${CONTROL_UI_TERMINAL_ENABLED_ATTRIBUTE}="${allowWasm === true}"`,
);
const hashes = computeInlineScriptHashes(prepared);
// 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.
@@ -927,8 +935,9 @@ export async function handleControlUiHttpRequest(
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;
// for an explicitly enabled terminal so the default policy stays strict.
const terminalEnabled =
opts?.terminalEnabled ?? opts?.config?.gateway?.terminal?.enabled === true;
const route = classifyControlUiRequest({
basePath,
pathname,
@@ -1005,7 +1014,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,
terminalEnabled,
} satisfies ControlUiBootstrapConfig);
return true;
}
+2
View File
@@ -77,6 +77,8 @@ export function createLocalGatewayRequestContext(
cron: unavailableCron,
cronStorePath: "",
getRuntimeConfig: params.getRuntimeConfig,
resolveTerminalLaunchPolicy: () => ({ ok: false, block: { kind: "disabled" } }),
isTerminalEnabled: () => false,
loadGatewayModelCatalog: async () =>
loadManifestModelCatalog({ config: params.getRuntimeConfig() }),
getHealthCache: () => null,
+3
View File
@@ -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,
@@ -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<PluginApprovalRequestPayload>;
loadGatewayModelCatalog: (params?: { readOnly?: boolean }) => Promise<ModelCatalogEntry[]>;
+57 -2
View File
@@ -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", () => {
+3 -12
View File
@@ -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"));
@@ -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) {
+4
View File
@@ -222,6 +222,8 @@ type ManagedGatewayConfigReloaderParams = Omit<
resolveSharedGatewaySessionGenerationForConfig: (config: OpenClawConfig) => string | undefined;
sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState;
clients: Iterable<SharedGatewayAuthClient>;
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;
@@ -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 () => []),
+4
View File
@@ -16,6 +16,8 @@ export type GatewayRequestContextParams = {
deps: GatewayRequestContext["deps"];
runtimeState: Pick<GatewayServerLiveState, "cronState">;
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,
+2
View File
@@ -100,6 +100,7 @@ export async function createGatewayRuntimeState(params: {
logHooks: ReturnType<typeof createSubsystemLogger>;
logPlugins: ReturnType<typeof createSubsystemLogger>;
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
+12
View File
@@ -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,
+205 -8
View File
@@ -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" });
+133
View File
@@ -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<string, TerminalLaunchBlock>();
const blockedAgentsUntilCommit = new Map<string, TerminalLaunchBlock>();
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<string, string> {
const env: Record<string, string> = {};
@@ -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<void>((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()];
+28 -7
View File
@@ -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.
@@ -22,6 +22,7 @@ export async function createGatewayRuntimeStateForTest(
openResponsesEnabled: false,
resolvedAuth: {} as never,
getResolvedAuth: () => ({}) as never,
isTerminalEnabled: () => false,
hooksConfig: () => null,
getHookClientIpConfig: () => ({}) as never,
pluginRegistry,
+1
View File
@@ -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,
},
+1
View File
@@ -4274,6 +4274,7 @@ export function renderApp(state: AppViewState) {
: "dark";
return html`<openclaw-terminal-panel
.client=${state.client}
.agentId=${chatAgentId}
.available=${terminalAvailable}
.themeMode=${terminalMode}
></openclaw-terminal-panel>`;
+6 -2
View File
@@ -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;
@@ -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,
+12 -10
View File
@@ -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;
}
@@ -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);
+67
View File
@@ -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 <T>(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 },
});
});
});
});
+5 -1
View File
@@ -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.