fix(codex): expose selectable node exec (#104198)

* fix(codex): expose selectable node exec

* chore: defer Codex node release note
This commit is contained in:
Peter Steinberger
2026-07-10 22:50:55 -07:00
committed by GitHub
parent 96b1be10f5
commit 82abf7244d
3 changed files with 159 additions and 14 deletions
+6
View File
@@ -26,6 +26,12 @@ dynamic tools routed through the app-server `item/tool/call` bridge. An
active OpenClaw sandbox or restricted tool policy disables native code mode
entirely unless you opt into the experimental sandbox exec-server path.
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
(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.
This Codex-native feature is separate from
[OpenClaw code mode](/reference/code-mode), an opt-in QuickJS-WASI runtime
for generic OpenClaw runs with a different `exec` input shape. For the
@@ -855,6 +855,128 @@ describe("Codex app-server dynamic tool build", () => {
]);
});
it("exposes selectable node shell tools beside native shell for auto host runs", async () => {
const execTool = {
...createRuntimeDynamicTool("exec"),
parameters: {
type: "object",
properties: {
command: { type: "string" },
host: { type: "string" },
security: { type: "string" },
ask: { type: "string" },
node: { type: "string" },
},
required: ["command"],
additionalProperties: false,
},
};
vi.mocked(execTool.execute).mockResolvedValueOnce({
content: [{ type: "text", text: "arm64" }],
details: { status: "completed" },
});
const processTool = createRuntimeDynamicTool("process");
setOpenClawCodingToolsFactoryForTests(() => [
execTool,
processTool,
createRuntimeDynamicTool("message"),
]);
const sessionFile = path.join(tempDir, "auto-node-session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeToolSurfaceEnabled: true,
});
expect(tools.map((tool) => tool.name)).toEqual(["message", "node_exec", "node_process"]);
const nodeExec = tools.find((tool) => tool.name === "node_exec");
expect(nodeExec?.description).toContain("Select the node by name or id");
expect(nodeExec?.parameters).toEqual({
type: "object",
properties: {
command: { type: "string" },
node: { type: "string" },
},
required: ["command"],
additionalProperties: false,
});
await nodeExec?.execute(
"call-auto-node",
{
command: "/usr/bin/uname -m",
node: "mac-mini",
host: "gateway",
security: "full",
ask: "off",
},
undefined,
);
expect(execTool.execute).toHaveBeenCalledWith(
"call-auto-node",
{
command: "/usr/bin/uname -m",
node: "mac-mini",
host: "node",
},
undefined,
undefined,
);
vi.mocked(execTool.execute).mockResolvedValueOnce({
content: [{ type: "text", text: "arm64" }],
details: { status: "completed" },
});
const boundAutoParams = createParams(
path.join(tempDir, "bound-auto-node-session.jsonl"),
workspaceDir,
);
boundAutoParams.disableTools = false;
boundAutoParams.runtimePlan = createCodexRuntimePlanFixture();
boundAutoParams.config = {
tools: { exec: { host: "auto", node: "bound-mac-mini" } },
} as never;
const boundAutoTools = await buildDynamicToolsForTest(boundAutoParams, workspaceDir, {
nativeToolSurfaceEnabled: true,
});
const boundNodeExec = boundAutoTools.find((tool) => tool.name === "node_exec");
expect(boundNodeExec?.parameters).toEqual({
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
additionalProperties: false,
});
await boundNodeExec?.execute(
"call-bound-auto-node",
{ command: "/usr/bin/uname -m", node: "other-node" },
undefined,
);
expect(execTool.execute).toHaveBeenLastCalledWith(
"call-bound-auto-node",
{
command: "/usr/bin/uname -m",
node: "bound-mac-mini",
host: "node",
},
undefined,
undefined,
);
const gatewayParams = createParams(
path.join(tempDir, "gateway-node-session.jsonl"),
workspaceDir,
);
gatewayParams.disableTools = false;
gatewayParams.runtimePlan = createCodexRuntimePlanFixture();
gatewayParams.execOverrides = { host: "gateway" };
const gatewayTools = await buildDynamicToolsForTest(gatewayParams, workspaceDir, {
nativeToolSurfaceEnabled: true,
});
expect(gatewayTools.map((tool) => tool.name)).toEqual(["message"]);
});
it("exposes Docker sandbox shell tools when native Code Mode cannot honor sandbox paths", async () => {
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
@@ -882,7 +1004,7 @@ describe("Codex app-server dynamic tool build", () => {
);
});
it("does not expose sandbox shell tools when sandbox routing is disabled", async () => {
it("exposes node shell but not sandbox shell tools when sandbox routing is disabled", async () => {
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("exec"),
createRuntimeDynamicTool("process"),
@@ -898,7 +1020,11 @@ describe("Codex app-server dynamic tool build", () => {
sandbox: { enabled: false, backendId: "ssh" } as never,
});
expect(disabledSandboxTools.map((tool) => tool.name)).toEqual(["message"]);
expect(disabledSandboxTools.map((tool) => tool.name)).toEqual([
"message",
"node_exec",
"node_process",
]);
});
it("does not expose sandbox_exec without a matching process follow-up tool", async () => {
@@ -59,7 +59,7 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
const CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES = new Set(["host", "security", "ask", "node"]);
const CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]);
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
export type DynamicToolBuildParams = {
@@ -775,7 +775,10 @@ function addNodeShellDynamicToolsIfNeeded(
if (isCodexMemoryFlushRun(input.params)) {
return filteredTools;
}
if (nodePolicy.effectiveExecHost !== "node") {
const nodeExecIsDefault = nodePolicy.effectiveExecHost === "node";
const nodeExecAvailableFromAuto =
nodePolicy.requestedExecHost === "auto" && nodePolicy.effectiveExecHost === "gateway";
if (!nodeExecIsDefault && !nodeExecAvailableFromAuto) {
return filteredTools;
}
const execTool = allTools.find((tool) => normalizeCodexDynamicToolName(tool.name) === "exec");
@@ -812,16 +815,20 @@ function createNodeExecDynamicTool(
execTool: OpenClawDynamicTool,
configuredNode: string | undefined,
): OpenClawDynamicTool {
const pinnedNode = configuredNode?.trim();
return {
...execTool,
name: CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME,
description:
"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 node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work.",
parameters: hideNodeExecDynamicToolParameters(execTool.parameters),
description: 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 node_process for follow-up on backgrounded node_exec sessions. 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 node_process for follow-up on backgrounded node_exec sessions. Use Codex's native shell for local app-server work.",
parameters: hideNodeExecDynamicToolParameters(execTool.parameters, {
hideNode: Boolean(pinnedNode),
}),
execute: async (toolCallId, args, signal, onUpdate) => {
const result = await execTool.execute(
toolCallId,
pinNodeExecDynamicToolArgs(args, configuredNode),
pinNodeExecDynamicToolArgs(args, pinnedNode),
signal,
onUpdate,
);
@@ -847,7 +854,7 @@ function createNodeProcessDynamicTool(processTool: OpenClawDynamicTool): OpenCla
...processTool,
name: CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME,
description:
"Manage node_exec sessions that were started on the OpenClaw configured remote node for this session: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for node_exec follow-up; use Codex's native shell session handling for local app-server work.",
"Manage node_exec sessions that were started on OpenClaw remote nodes: list, poll, log, write, send-keys, submit, paste, kill, clear, or remove. Use only for node_exec follow-up; use Codex's native shell session handling for local app-server work.",
};
}
@@ -856,8 +863,8 @@ function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | unde
args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
: {};
const { host: _host, security: _security, ask: _ask, node: _node, ...rest } = source;
const node = configuredNode?.trim();
const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source;
const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : "");
return {
...rest,
host: "node",
@@ -865,7 +872,10 @@ function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | unde
};
}
function hideNodeExecDynamicToolParameters(parameters: OpenClawDynamicTool["parameters"]) {
function hideNodeExecDynamicToolParameters(
parameters: OpenClawDynamicTool["parameters"],
options: { hideNode: boolean },
) {
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
return parameters;
}
@@ -876,7 +886,9 @@ function hideNodeExecDynamicToolParameters(parameters: OpenClawDynamicTool["para
}
const nextProperties = Object.fromEntries(
Object.entries(rawProperties).filter(
([name]) => !CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)),
([name]) =>
!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node"),
),
);
const rawRequired = schema.required;
@@ -884,7 +896,8 @@ function hideNodeExecDynamicToolParameters(parameters: OpenClawDynamicTool["para
? rawRequired.filter(
(name) =>
typeof name !== "string" ||
!CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)),
(!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) &&
!(options.hideNode && normalizeCodexDynamicToolName(name) === "node")),
)
: rawRequired;
return {