fix(codex): prevent node process control from targeting gateway sessions (#126253)

This commit is contained in:
Peter Steinberger
2026-08-19 01:31:52 -07:00
committed by GitHub
parent 30337c8962
commit fef5fc55f4
6 changed files with 103 additions and 87 deletions
+10 -9
View File
@@ -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
@@ -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",
]);
});
@@ -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<T extends { name: string }>(
(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"))
);
});
}
@@ -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<string, unknown> {
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,
+2 -1
View File
@@ -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,
@@ -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;
}