From fef5fc55f45703e12738dfbe5bf93cfd2a4f6635 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 01:31:52 -0700 Subject: [PATCH] fix(codex): prevent node process control from targeting gateway sessions (#126253) --- docs/plugins/codex-harness.md | 19 ++--- .../src/app-server/dynamic-tool-build.test.ts | 74 ++++++++++++------- .../src/app-server/dynamic-tool-build.ts | 33 ++------- .../src/app-server/shell-dynamic-tools.ts | 56 +++++++------- src/agents/bash-tools.exec-run.ts | 3 +- .../bash-tools.exec.approval-id.test.ts | 5 ++ 6 files changed, 103 insertions(+), 87 deletions(-) diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index fbc3d15016cb..e5266019781b 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -57,18 +57,19 @@ existing per-session OpenClaw process scope for background follow-up. Prefer Codex native shell for ordinary local work. With the default `tools.exec.host: "auto"` and no active OpenClaw sandbox, -Codex also receives `node_exec` and `node_process` tools for commands on paired -nodes. Native shell remains on the Codex app-server host and workspace +Codex also receives `node_exec` for commands on paired nodes. Native shell +remains on the Codex app-server host and workspace (Gateway-local for the default stdio deployment); `node_exec` selects a node by -name or id and keeps OpenClaw's node approval policy in force. If a finite -runtime allowlist disables native Code Mode and leaves the turn without an -execution environment, OpenClaw keeps its policy-filtered `exec` and `process` -tools available instead for direct, unsandboxed execution. +name or id, keeps OpenClaw's node approval policy in force, and waits for the +remote command to finish. Remote-node background follow-up is not available. If +a finite runtime allowlist disables native Code Mode and leaves the turn without +an execution environment, OpenClaw keeps its policy-filtered `exec` and +`process` tools available instead for direct, unsandboxed execution. When `tools.exec.host: "node"` or `/exec host=node` makes the node the session -default, OpenClaw hides the Codex-native shell and exposes `node_exec` and -`node_process` as the shell path. This keeps the configured execution host from -silently falling back to the app-server or Gateway machine. +default, OpenClaw hides the Codex-native shell and exposes `node_exec` as the +shell path. This keeps the configured execution host from silently falling +back to the app-server or Gateway machine. `gateway_exec` is not exposed when an active OpenClaw sandbox, a node-default execution policy, memory-flush restrictions, tool allow/deny policy, or diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 4e040121cf3d..68c4e84a62de 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -1350,8 +1350,12 @@ describe("Codex app-server dynamic tool build", () => { security: { type: "string" }, ask: { type: "string" }, node: { type: "string" }, + background: { type: "boolean" }, + yieldMs: { type: "number" }, + pty: { type: "boolean" }, + elevated: { type: "boolean" }, }, - required: ["command", "host", "node"], + required: ["command", "host", "node", "background", "yieldMs", "pty", "elevated"], additionalProperties: false, }, }; @@ -1364,12 +1368,7 @@ describe("Codex app-server dynamic tool build", () => { ], details: { status: "running" }, }); - const processTool = createRuntimeDynamicTool("process"); - setOpenClawCodingToolsFactoryForTests(() => [ - execTool, - processTool, - createRuntimeDynamicTool("message"), - ]); + setOpenClawCodingToolsFactoryForTests(() => [execTool, createRuntimeDynamicTool("message")]); const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); @@ -1388,11 +1387,10 @@ describe("Codex app-server dynamic tool build", () => { }); expect(nativeToolSurfaceEnabled).toBe(false); - expect(tools.map((tool) => tool.name)).toEqual(["message", "node_exec", "node_process"]); + expect(tools.map((tool) => tool.name)).toEqual(["message", "node_exec"]); const nodeExec = tools.find((tool) => tool.name === "node_exec"); - const nodeProcess = tools.find((tool) => tool.name === "node_process"); expect(nodeExec?.description).toContain("host=node internally"); - expect(nodeProcess?.description).toContain("background shell sessions"); + expect(nodeExec?.description).toContain("background follow-up is unavailable"); expect(nodeExec?.parameters).toEqual({ type: "object", properties: { @@ -1410,6 +1408,10 @@ describe("Codex app-server dynamic tool build", () => { node: "model-selected-node", security: "full", ask: "off", + background: true, + yieldMs: 10, + pty: true, + elevated: true, }, undefined, ); @@ -1426,7 +1428,7 @@ describe("Codex app-server dynamic tool build", () => { expect(result?.content).toEqual([ { type: "text", - text: "Command still running (session exec-1, pid 123). Use remote-node background-session control (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up when available.", + text: "Command still running (session exec-1, pid 123). Remote-node background follow-up is unavailable. Wait for the command to complete.", }, ]); @@ -1456,14 +1458,10 @@ describe("Codex app-server dynamic tool build", () => { }); expect(runtimePolicyNativeToolSurfaceEnabled).toBe(false); - expect(runtimePolicyTools.map((tool) => tool.name)).toEqual([ - "message", - "node_exec", - "node_process", - ]); + expect(runtimePolicyTools.map((tool) => tool.name)).toEqual(["message", "node_exec"]); }); - it("exposes selectable node shell tools beside native shell for auto host runs", async () => { + it("does not expose Gateway process sessions as remote-node control in auto host runs", async () => { const execTool = { ...createRuntimeDynamicTool("exec"), parameters: { @@ -1504,8 +1502,39 @@ describe("Codex app-server dynamic tool build", () => { "gateway_exec", "gateway_process", "node_exec", - "node_process", ]); + const bridge = createCodexDynamicToolBridge({ + tools, + signal: new AbortController().signal, + loading: "direct", + }); + const gatewayList = await bridge.handleToolCall({ + threadId: "auto-thread", + turnId: "auto-turn", + tool: "gateway_process", + callId: "gateway-process-list", + arguments: { action: "list" }, + }); + expect(gatewayList.success).toBe(true); + expect(processTool.execute).toHaveBeenCalledWith( + "gateway-process-list", + { action: "list" }, + expect.any(AbortSignal), + undefined, + ); + vi.mocked(processTool.execute).mockClear(); + const nodeList = await bridge.handleToolCall({ + threadId: "auto-thread", + turnId: "auto-turn", + tool: "node_process", + callId: "node-process-list", + arguments: { action: "list" }, + }); + expect(nodeList.success).toBe(false); + expect(nodeList.contentItems).toEqual([ + { type: "inputText", text: "Unknown OpenClaw tool: node_process" }, + ]); + expect(processTool.execute).not.toHaveBeenCalled(); const nodeExec = tools.find((tool) => tool.name === "node_exec"); expect(nodeExec?.description).toContain("Select the node by name or id"); expect(nodeExec?.parameters).toEqual({ @@ -1621,13 +1650,7 @@ describe("Codex app-server dynamic tool build", () => { }); expect(nativeToolSurfaceEnabled).toBe(false); - expect(tools.map((tool) => tool.name)).toEqual([ - "exec", - "process", - "message", - "node_exec", - "node_process", - ]); + expect(tools.map((tool) => tool.name)).toEqual(["exec", "process", "message", "node_exec"]); const bridge = createCodexDynamicToolBridge({ tools, @@ -1711,7 +1734,6 @@ describe("Codex app-server dynamic tool build", () => { "process", "message", "node_exec", - "node_process", ]); }); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 3c9104885fdd..2a22a97dd67f 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -46,9 +46,8 @@ import { CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME, CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME, CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME, - CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, createExecAliasDynamicTool, - createProcessAliasDynamicTool, + createGatewayProcessAliasDynamicTool, isCodexDynamicToolExcluded, } from "./shell-dynamic-tools.js"; import { filterCodexVisionTools } from "./vision-tools.js"; @@ -585,7 +584,7 @@ function addGatewayShellDynamicToolsIfAvailable( }), ]; if (processAliasAvailable && processTool) { - toolsToAppend.push(createProcessAliasDynamicTool(processTool, "gateway")); + toolsToAppend.push(createGatewayProcessAliasDynamicTool(processTool)); } return [...filteredTools, ...toolsToAppend]; } @@ -874,35 +873,21 @@ function addNodeShellDynamicToolsIfNeeded( return filteredTools; } const execTool = allTools.find((tool) => normalizeCodexDynamicToolName(tool.name) === "exec"); - const processTool = allTools.find( - (tool) => normalizeCodexDynamicToolName(tool.name) === "process", - ); - if (!execTool || !processTool) { + if (!execTool) { return filteredTools; } - const toolsToAppend: OpenClawDynamicTool[] = []; if ( !isCodexDynamicToolExcluded(input.pluginConfig, ["exec", CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME]) && !filteredTools.some( (tool) => normalizeCodexDynamicToolName(tool.name) === CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME, ) ) { - toolsToAppend.push( + return [ + ...filteredTools, createExecAliasDynamicTool(execTool, { host: "node", node: nodePolicy.node }), - ); + ]; } - if ( - !isCodexDynamicToolExcluded(input.pluginConfig, [ - "process", - CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, - ]) && - !filteredTools.some( - (tool) => normalizeCodexDynamicToolName(tool.name) === CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, - ) - ) { - toolsToAppend.push(createProcessAliasDynamicTool(processTool, "node")); - } - return toolsToAppend.length > 0 ? [...filteredTools, ...toolsToAppend] : filteredTools; + return filteredTools; } function shouldKeepOpenClawShellDynamicTools( input: DynamicToolBuildParams, @@ -956,9 +941,7 @@ function filterCodexDynamicToolsForAllowlist( (normalized === CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME && allowSet.has("exec")) || (normalized === CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME && (allowSet.has("exec") || allowSet.has("process"))) || - (normalized === CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME && allowSet.has("exec")) || - (normalized === CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME && - (allowSet.has("exec") || allowSet.has("process"))) + (normalized === CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME && allowSet.has("exec")) ); }); } diff --git a/extensions/codex/src/app-server/shell-dynamic-tools.ts b/extensions/codex/src/app-server/shell-dynamic-tools.ts index 6408132058ba..4d4e39f4d507 100644 --- a/extensions/codex/src/app-server/shell-dynamic-tools.ts +++ b/extensions/codex/src/app-server/shell-dynamic-tools.ts @@ -9,10 +9,16 @@ type ExecAliasParams = | { host: "node"; node?: string }; export const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec"; -export const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process"; export const CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME = "gateway_exec"; export const CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME = "gateway_process"; const CODEX_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]); +const CODEX_NODE_EXEC_PARAMETER_NAMES = new Set([ + "command", + "workdir", + "env", + "timeoutSeconds", + "node", +]); const PROCESS_FOLLOWUP_TEXT = "Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up."; @@ -37,11 +43,11 @@ export function createExecAliasDynamicTool( const name = nodeAlias ? CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME : CODEX_GATEWAY_EXEC_DYNAMIC_TOOL_NAME; const description = nodeAlias ? pinnedNode - ? "Run a shell command on the OpenClaw configured remote node for this session. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use remote-node background-session control for follow-up when available. Use Codex's native shell for local app-server work." - : "Run a shell command on an OpenClaw remote node. Select the node by name or id when multiple nodes are available. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Use remote-node background-session control for follow-up when available. Use Codex's native shell for local app-server work." + ? "Run a shell command to completion on the OpenClaw configured remote node for this session. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Remote-node background follow-up is unavailable. Use Codex's native shell for local app-server work." + : "Run a shell command to completion on an OpenClaw remote node. Select the node by name or id when multiple nodes are available. This tool always uses OpenClaw host=node internally and follows the existing node exec approval and allowlist policy. Remote-node background follow-up is unavailable. Use Codex's native shell for local app-server work." : "Run a shell command through OpenClaw on the Gateway host for OpenClaw-managed Gateway environment access, including Secret Store agent-readable environment values and protected egress sentinels. Native Codex shell remains preferred for ordinary local work. This tool always uses OpenClaw host=gateway internally and follows Gateway exec approval and allowlist policy."; const followupText = nodeAlias - ? "Use remote-node background-session control (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up when available." + ? "Remote-node background follow-up is unavailable. Wait for the command to complete." : gatewayProcessAliasAvailable ? "Use gateway_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up." : "Background session follow-up is unavailable because gateway_process is not exposed. Rerun without background=true and set yieldMs high enough to wait for completion."; @@ -52,6 +58,7 @@ export function createExecAliasDynamicTool( parameters: hideExecDynamicToolParameters( execTool.parameters, !nodeAlias || Boolean(pinnedNode), + nodeAlias, ), execute: async (toolCallId, args, signal, onUpdate) => { const result = await execTool.execute( @@ -74,18 +81,15 @@ export function createExecAliasDynamicTool( }; } -export function createProcessAliasDynamicTool( +export function createGatewayProcessAliasDynamicTool( processTool: OpenClawDynamicTool, - host: "gateway" | "node", ): OpenClawDynamicTool { - const nodeAlias = host === "node"; - const name = nodeAlias - ? CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME - : CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME; - const description = nodeAlias - ? "Manage background shell sessions on OpenClaw remote nodes: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for remote-node follow-up; use Codex's native shell session handling for local app-server work." - : "Manage background shell sessions in the existing per-session OpenClaw process scope: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use for gateway_exec follow-up; use native Codex shell session handling for ordinary local work."; - return { ...processTool, name, description }; + return { + ...processTool, + name: CODEX_GATEWAY_PROCESS_DYNAMIC_TOOL_NAME, + description: + "Manage background shell sessions in the existing per-session OpenClaw process scope: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use for gateway_exec follow-up; use native Codex shell session handling for ordinary local work.", + }; } function pinExecDynamicToolArgs( @@ -98,9 +102,12 @@ function pinExecDynamicToolArgs( if (host === "gateway") { return { ...rest, host }; } + const nodeArgs = Object.fromEntries( + Object.entries(rest).filter(([name]) => CODEX_NODE_EXEC_PARAMETER_NAMES.has(name)), + ); const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : ""); return { - ...rest, + ...nodeArgs, host, ...(node ? { node } : {}), }; @@ -115,6 +122,7 @@ function normalizeExecDynamicToolArgs(args: unknown): Record { function hideExecDynamicToolParameters( parameters: OpenClawDynamicTool["parameters"], hideNode: boolean, + nodeOnly: boolean, ) { if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { return parameters; @@ -124,21 +132,17 @@ function hideExecDynamicToolParameters( if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) { return parameters; } + const includeParameter = (name: string) => + nodeOnly + ? CODEX_NODE_EXEC_PARAMETER_NAMES.has(name) && !(hideNode && name === "node") + : !CODEX_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && + !(hideNode && normalizeCodexDynamicToolName(name) === "node"); const nextProperties = Object.fromEntries( - Object.entries(rawProperties).filter( - ([name]) => - !CODEX_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && - !(hideNode && normalizeCodexDynamicToolName(name) === "node"), - ), + Object.entries(rawProperties).filter(([name]) => includeParameter(name)), ); const rawRequired = schema.required; const nextRequired = Array.isArray(rawRequired) - ? rawRequired.filter( - (name) => - typeof name !== "string" || - (!CODEX_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && - !(hideNode && normalizeCodexDynamicToolName(name) === "node")), - ) + ? rawRequired.filter((name) => typeof name !== "string" || includeParameter(name)) : rawRequired; return { ...schema, diff --git a/src/agents/bash-tools.exec-run.ts b/src/agents/bash-tools.exec-run.ts index ebbf4e40c037..4002af530f7a 100644 --- a/src/agents/bash-tools.exec-run.ts +++ b/src/agents/bash-tools.exec-run.ts @@ -469,7 +469,8 @@ export function createExecTool( approvalRunningNoticeMs, warnings, foregroundWarnings: foregroundFallbackWarning ? [foregroundFallbackWarning] : [], - processContinuationAvailable: allowBackground, + // Remote system.run has no process-session owner. + processContinuationAvailable: false, notifySessionKey, notifyOnExit, trustedSafeBinDirs, diff --git a/src/agents/bash-tools.exec.approval-id.test.ts b/src/agents/bash-tools.exec.approval-id.test.ts index 780f9610cc07..ff0486f01a1a 100644 --- a/src/agents/bash-tools.exec.approval-id.test.ts +++ b/src/agents/bash-tools.exec.approval-id.test.ts @@ -244,12 +244,17 @@ function expectPendingApprovalText( expect(pendingText).toContain(options.command); if (options.interactive) { expect(pendingText).toContain("Mode: foreground (interactive approvals available)."); + } + if (options.interactive && options.host !== "node") { expect(pendingText).toContain( (options.allowedDecisions ?? "").includes("allow-always") ? "Background mode requires pre-approved policy" : "Background mode requires an effective policy that allows pre-approval", ); } + if (options.host === "node") { + expect(pendingText).not.toContain("Background mode"); + } return details; }