diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 84ad36b62a88..9aa8fa2e4a9d 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -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 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 670b068dc1c2..0154b3c1580a 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -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 () => { diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index d72f324b663d..97a545c2fea9 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -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) : {}; - 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 {