From 3601dcad24fea8924c52d8abbafb85e50fc247ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Jul 2026 03:03:13 -0700 Subject: [PATCH] fix(gateway): reject unknown agent ids before resolving terminal isolation --- src/gateway/server-methods/terminal.ts | 8 ++++++ src/gateway/terminal/launch.test.ts | 39 ++++++++++++++++++++++++++ src/gateway/terminal/launch.ts | 12 +++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index 7fbb141ade47..c2899f538e4d 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -67,6 +67,14 @@ export const terminalHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); return; } + if (launch.block.kind === "unknown-agent") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${launch.block.agentId}"`), + ); + return; + } // Fail closed: a sandboxed agent must never receive a host shell. respond( false, diff --git a/src/gateway/terminal/launch.test.ts b/src/gateway/terminal/launch.test.ts index 84779c3e08d6..817e405f0f28 100644 --- a/src/gateway/terminal/launch.test.ts +++ b/src/gateway/terminal/launch.test.ts @@ -90,6 +90,45 @@ describe("resolveTerminalLaunch", () => { expect(result.plan.cwd).toBe(workspace); } }); + + it("fails closed for an unknown explicit agent id", () => { + // Every configured agent is fully sandboxed; an unknown id must not fall + // through to the (unsandboxed) global defaults and a host-home shell. + const config = { + agents: { + list: [{ id: "locked", sandbox: { mode: "all" } }], + }, + } as unknown as OpenClawConfig; + const result = resolveTerminalLaunch({ + config, + enabled: true, + agentId: "ghost", + env: { SHELL: "/bin/zsh" }, + platform: "linux", + }); + expect(result).toEqual({ ok: false, block: { kind: "unknown-agent", agentId: "ghost" } }); + }); + + it("accepts an explicit id that names a configured agent", () => { + const workspace = mkdtempSync(path.join(os.tmpdir(), "term-ws-id-")); + const config = { + agents: { + defaults: { workspace }, + list: [{ id: "Ops" }], + }, + } as unknown as OpenClawConfig; + const result = resolveTerminalLaunch({ + config, + enabled: true, + agentId: "ops", + env: { SHELL: "/bin/zsh" }, + platform: "linux", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.plan.agentId).toBe("ops"); + } + }); }); describe("buildTerminalEnv", () => { diff --git a/src/gateway/terminal/launch.ts b/src/gateway/terminal/launch.ts index de25721bfb44..f8673ebf99ee 100644 --- a/src/gateway/terminal/launch.ts +++ b/src/gateway/terminal/launch.ts @@ -4,15 +4,18 @@ import { existsSync, statSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { + listAgentIds, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../../agents/agent-scope-config.js"; import { resolveSandboxConfigForAgent } from "../../agents/sandbox/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; /** Why a terminal cannot open, or `null` when it can. */ export type TerminalLaunchBlock = | { kind: "disabled" } + | { kind: "unknown-agent"; agentId: string } | { kind: "sandboxed"; agentId: string; mode: "all" }; /** Resolved plan for a host terminal session. */ @@ -73,7 +76,14 @@ export function resolveTerminalLaunch(params: { return { ok: false, block: { kind: "disabled" } }; } const env = params.env ?? process.env; - const agentId = params.agentId?.trim() || resolveDefaultAgentId(params.config); + const requested = params.agentId?.trim(); + const agentId = requested ? normalizeAgentId(requested) : resolveDefaultAgentId(params.config); + // Fail closed on unknown ids: they would resolve against the *global* + // sandbox defaults and an invented workspace, sidestepping a per-agent + // `sandbox.mode: "all"` refusal below. + if (requested && !listAgentIds(params.config).includes(agentId)) { + return { ok: false, block: { kind: "unknown-agent", agentId } }; + } const sandbox = resolveSandboxConfigForAgent(params.config, agentId); // Only "all" sandboxes every session. Under "non-main" the agent's main // session still runs on the host, so a host terminal there is consistent with