diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index d8be8aad8c9c..7e0bb88a1bb9 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -467,9 +467,9 @@ and `HOME` stays inherited so subprocesses can use normal user-home state. ## Dynamic tools Codex dynamic tools default to `searchable` loading, exposed under the -`openclaw` namespace with `deferLoading: true`. OpenClaw does not expose -dynamic tools that duplicate Codex-native workspace operations or Codex's own -tool-search surface: +`openclaw` namespace with `deferLoading: true`. OpenClaw normally does not +expose dynamic tools that duplicate Codex-native workspace operations or +Codex's own tool-search surface: - `read` - `write` @@ -483,6 +483,11 @@ tool-search surface: - `tool_search` - `tool_search_code` +When a finite runtime allowlist disables native Code Mode, OpenClaw sends an +empty execution-environment selection. In that direct, unsandboxed case, +OpenClaw keeps its policy-filtered `exec` and `process` tools as the shell +fallback. Runtime allowlists and `codexDynamicToolsExclude` still apply. + Most remaining OpenClaw integration tools, such as messaging, media, cron, browser, nodes, gateway, `heartbeat_respond`, and `web_search`, are available through Codex tool search under that namespace. This keeps the initial model diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 9efce9727cb7..ec720a42ab24 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -39,7 +39,10 @@ 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. +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. This Codex-native feature is separate from [OpenClaw code mode](/reference/code-mode), an opt-in QuickJS-WASI runtime @@ -623,14 +626,16 @@ normal user-home state. ### Dynamic tools and web search -Codex dynamic tools default to `searchable` loading. OpenClaw does not -expose dynamic tools that duplicate Codex-native workspace operations: +Codex dynamic tools default to `searchable` loading. OpenClaw normally does +not expose dynamic tools that duplicate Codex-native workspace operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, `update_plan`, `tool_call`, `tool_describe`, `tool_search`, and `tool_search_code`. Most remaining OpenClaw integration tools, such as messaging, media, cron, browser, nodes, gateway, and `heartbeat_respond`, are available through Codex tool search under the `openclaw` namespace, keeping the initial model -context smaller. +context smaller. The restricted-turn shell fallback is the exception for +`exec` and `process` when a finite allowlist disables native Code Mode; +runtime allowlists and `codexDynamicToolsExclude` still apply. Tools marked `catalogMode: "direct-only"`, including the OpenClaw `computer` tool, use the `openclaw_direct` namespace instead. Codex treats that namespace 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 a486836e2dca..ca3b2c95e179 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -981,6 +981,73 @@ describe("Codex app-server dynamic tool build", () => { nativeToolSurfaceEnabled: true, }); expect(gatewayTools.map((tool) => tool.name)).toEqual(["message"]); + + const allowlistedParams = { + ...gatewayParams, + toolsAllow: ["message"], + } as EmbeddedRunAttemptParams; + const allowlistedTools = await buildDynamicToolsForTest(allowlistedParams, workspaceDir, { + nativeToolSurfaceEnabled: true, + }); + expect(allowlistedTools.map((tool) => tool.name)).toEqual(["message"]); + }); + + it("restores the policy-filtered OpenClaw shell when a finite allowlist disables native Code Mode", async () => { + const execTool = createRuntimeDynamicTool("exec"); + const processTool = createRuntimeDynamicTool("process"); + const messageTool = createRuntimeDynamicTool("message"); + setOpenClawCodingToolsFactoryForTests(() => [execTool, processTool, messageTool]); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "restricted-session.jsonl"), workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + params.toolsAllow = ["exec", "process", "message"]; + const nativeToolSurfaceEnabled = shouldEnableCodexAppServerNativeToolSurface(params); + + const tools = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled, + }); + + expect(nativeToolSurfaceEnabled).toBe(false); + expect(tools.map((tool) => tool.name)).toEqual([ + "exec", + "process", + "message", + "node_exec", + "node_process", + ]); + + const bridge = createCodexDynamicToolBridge({ + tools, + signal: new AbortController().signal, + loading: "direct", + }); + await bridge.handleToolCall({ + threadId: "restricted-thread", + turnId: "restricted-turn", + tool: "exec", + callId: "restricted-exec", + arguments: { command: "echo restored" }, + }); + expect(execTool.execute).toHaveBeenCalledWith( + "restricted-exec", + { command: "echo restored" }, + expect.any(AbortSignal), + undefined, + ); + + const excludedTools = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled, + pluginConfig: { codexDynamicToolsExclude: ["exec", "process"] }, + }); + expect(excludedTools.map((tool) => tool.name)).toEqual(["message"]); + + const messageOnlyTools = await buildDynamicToolsForTest( + { ...params, toolsAllow: ["message"] }, + workspaceDir, + { nativeToolSurfaceEnabled: false }, + ); + expect(messageOnlyTools.map((tool) => tool.name)).toEqual(["message"]); }); it("exposes Docker sandbox shell tools when native Code Mode cannot honor sandbox paths", async () => { @@ -1024,9 +1091,12 @@ describe("Codex app-server dynamic tool build", () => { const disabledSandboxTools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: { enabled: false, backendId: "ssh" } as never, + nativeToolSurfaceEnabled: false, }); expect(disabledSandboxTools.map((tool) => tool.name)).toEqual([ + "exec", + "process", "message", "node_exec", "node_process", @@ -1046,6 +1116,7 @@ describe("Codex app-server dynamic tool build", () => { const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: { enabled: true, backendId: "ssh" } as never, + nativeToolSurfaceEnabled: false, }); expect(tools.map((tool) => tool.name)).toEqual(["message"]); @@ -1066,6 +1137,7 @@ describe("Codex app-server dynamic tool build", () => { for (const excludedToolName of ["sandbox_exec", "process"]) { const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: { enabled: true, backendId: "ssh" } as never, + nativeToolSurfaceEnabled: false, pluginConfig: { codexDynamicToolsExclude: [excludedToolName] }, }); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 9eb9d09dfc89..614b4bb3b61d 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -24,6 +24,7 @@ import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js"; import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { filterCodexDynamicTools, + filterCodexDynamicToolsWithOpenClawShell, isSystemAgentOnlyCodexDynamicToolAllowlist, isForcedPrivateQaCodexRuntime, normalizeCodexDynamicToolName, @@ -36,6 +37,13 @@ import { } from "./native-execution-policy.js"; import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js"; import type { CodexSandboxExecEnvironment } from "./sandbox-exec-server.js"; +import { + CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME, + CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, + createNodeExecDynamicTool, + createNodeProcessDynamicTool, + isCodexDynamicToolExcluded, +} from "./shell-dynamic-tools.js"; import { filterToolsForVisionInputs } from "./vision-tools.js"; import { resolveCodexWebSearchPlan, type CodexNativeWebSearchSupport } from "./web-search.js"; @@ -60,9 +68,6 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [ "apply_patch", ] as const; 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_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]); function preserveRingZeroSystemAgentTool( allTools: T[], filteredTools: T[], @@ -338,7 +343,9 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport, }); const readableAllTools = [...readableAllToolProjection.tools]; - const normallyProfiledTools = filterCodexDynamicTools(readableAllTools, input.pluginConfig); + const normallyProfiledTools = shouldKeepOpenClawShellDynamicTools(input, nativeExecutionPolicy) + ? filterCodexDynamicToolsWithOpenClawShell(readableAllTools, input.pluginConfig) + : filterCodexDynamicTools(readableAllTools, input.pluginConfig); const hostSystemAgentActive = input.isHostScopedToolActive?.("openclaw") ?? isHostScopedAgentToolActive("openclaw"); const profileFilteredTools = @@ -736,13 +743,6 @@ function shouldExposeSandboxExecDynamicTool(input: DynamicToolBuildParams): bool const backendId = input.sandbox?.enabled ? input.sandbox.backendId.trim().toLowerCase() : ""; return Boolean(backendId && input.nativeToolSurfaceEnabled === false); } -function isCodexDynamicToolExcluded(config: CodexPluginConfig, names: string[]): boolean { - const normalizedNames = new Set(names.map((name) => normalizeCodexDynamicToolName(name))); - return (config.codexDynamicToolsExclude ?? []).some((name) => { - const normalized = normalizeCodexDynamicToolName(name); - return normalizedNames.has(normalized); - }); -} function isSandboxShellDynamicToolExcluded(config: CodexPluginConfig): boolean { return isCodexDynamicToolExcluded(config, ["exec", "sandbox_exec", "process", "sandbox_process"]); } @@ -790,97 +790,18 @@ function addNodeShellDynamicToolsIfNeeded( } return toolsToAppend.length > 0 ? [...filteredTools, ...toolsToAppend] : filteredTools; } -function createNodeExecDynamicTool( - execTool: OpenClawDynamicTool, - configuredNode: string | undefined, -): OpenClawDynamicTool { - const pinnedNode = configuredNode?.trim(); - return { - ...execTool, - name: CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME, - 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, pinnedNode), - signal, - onUpdate, - ); - return { - ...result, - content: result.content.map((item) => - item.type === "text" - ? Object.assign({}, item, { - text: item.text.replace( - "Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.", - "Use node_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.", - ), - }) - : item, - ), - }; - }, - }; -} -function createNodeProcessDynamicTool(processTool: OpenClawDynamicTool): OpenClawDynamicTool { - return { - ...processTool, - name: CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, - description: - "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.", - }; -} -function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | undefined): unknown { - const source = - args && typeof args === "object" && !Array.isArray(args) - ? (args as Record) - : {}; - const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source; - const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : ""); - return { - ...rest, - host: "node", - ...(node ? { node } : {}), - }; -} -function hideNodeExecDynamicToolParameters( - parameters: OpenClawDynamicTool["parameters"], - options: { hideNode: boolean }, -) { - if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { - return parameters; - } - const schema = parameters as Record; - const rawProperties = schema.properties; - if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) { - return parameters; - } - const nextProperties = Object.fromEntries( - Object.entries(rawProperties).filter( - ([name]) => - !CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && - !(options.hideNode && normalizeCodexDynamicToolName(name) === "node"), - ), +function shouldKeepOpenClawShellDynamicTools( + input: DynamicToolBuildParams, + nodePolicy: CodexNativeExecutionPolicy, +): boolean { + return ( + !isCodexMemoryFlushRun(input.params) && + // Disabled native Code Mode sends `environments: []`, so Codex cannot + // advertise a shell. Preserve OpenClaw's policy-filtered direct shell. + input.nativeToolSurfaceEnabled === false && + input.sandbox?.enabled !== true && + nodePolicy.effectiveExecHost !== "node" ); - const rawRequired = schema.required; - const nextRequired = Array.isArray(rawRequired) - ? rawRequired.filter( - (name) => - typeof name !== "string" || - (!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && - !(options.hideNode && normalizeCodexDynamicToolName(name) === "node")), - ) - : rawRequired; - return { - ...schema, - properties: nextProperties, - ...(Array.isArray(rawRequired) ? { required: nextRequired } : {}), - }; } function resolveCodexNativeExecutionPolicyForDynamicTools( input: DynamicToolBuildParams, diff --git a/extensions/codex/src/app-server/dynamic-tool-profile.ts b/extensions/codex/src/app-server/dynamic-tool-profile.ts index 2383bbbb8502..bd0fd3ff2d57 100644 --- a/extensions/codex/src/app-server/dynamic-tool-profile.ts +++ b/extensions/codex/src/app-server/dynamic-tool-profile.ts @@ -21,6 +21,7 @@ const CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES = [ "tool_search", "tool_search_code", ] as const; +const CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES = new Set(["exec", "process"]); const DYNAMIC_TOOL_NAME_ALIASES: Record = { bash: "exec", @@ -113,10 +114,35 @@ export function filterCodexDynamicTools( tools: T[], config: Pick, env: CodexDynamicToolProfileEnv = process.env, +): T[] { + return filterCodexDynamicToolsWithOptions(tools, config, env, { + preserveOpenClawShell: false, + }); +} + +/** Keeps exec/process only when Codex cannot advertise an environment-backed native shell. */ +export function filterCodexDynamicToolsWithOpenClawShell( + tools: T[], + config: Pick, + env: CodexDynamicToolProfileEnv = process.env, +): T[] { + return filterCodexDynamicToolsWithOptions(tools, config, env, { + preserveOpenClawShell: true, + }); +} + +function filterCodexDynamicToolsWithOptions( + tools: T[], + config: Pick, + env: CodexDynamicToolProfileEnv, + options: { preserveOpenClawShell: boolean }, ): T[] { const excludes = new Set(); if (!isForcedPrivateQaCodexRuntime(env)) { for (const name of CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES) { + if (options.preserveOpenClawShell && CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES.has(name)) { + continue; + } excludes.add(name); } } diff --git a/extensions/codex/src/app-server/shell-dynamic-tools.ts b/extensions/codex/src/app-server/shell-dynamic-tools.ts new file mode 100644 index 000000000000..1ac095070269 --- /dev/null +++ b/extensions/codex/src/app-server/shell-dynamic-tools.ts @@ -0,0 +1,119 @@ +import type { CodexPluginConfig } from "./config.js"; +import { normalizeCodexDynamicToolName } from "./dynamic-tool-profile.js"; + +type OpenClawCodingToolsFactory = + (typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]; +type OpenClawDynamicTool = ReturnType[number]; + +export const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec"; +export const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process"; +const CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES = new Set(["host", "security", "ask"]); + +/** Returns true when plugin config explicitly removes any named dynamic tool. */ +export function isCodexDynamicToolExcluded( + config: Pick, + names: readonly string[], +): boolean { + const normalizedNames = new Set(names.map((name) => normalizeCodexDynamicToolName(name))); + return (config.codexDynamicToolsExclude ?? []).some((name) => + normalizedNames.has(normalizeCodexDynamicToolName(name)), + ); +} + +export function createNodeExecDynamicTool( + execTool: OpenClawDynamicTool, + configuredNode: string | undefined, +): OpenClawDynamicTool { + const pinnedNode = configuredNode?.trim(); + return { + ...execTool, + name: CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME, + 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, pinnedNode), + signal, + onUpdate, + ); + return { + ...result, + content: result.content.map((item) => + item.type === "text" + ? Object.assign({}, item, { + text: item.text.replace( + "Use process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.", + "Use node_process (list/poll/log/write/send-keys/submit/paste/kill/clear/remove) for follow-up.", + ), + }) + : item, + ), + }; + }, + }; +} + +export function createNodeProcessDynamicTool( + processTool: OpenClawDynamicTool, +): OpenClawDynamicTool { + return { + ...processTool, + name: CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME, + description: + "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.", + }; +} + +function pinNodeExecDynamicToolArgs(args: unknown, configuredNode: string | undefined): unknown { + const source = + args && typeof args === "object" && !Array.isArray(args) + ? (args as Record) + : {}; + const { host: _host, security: _security, ask: _ask, node: requestedNode, ...rest } = source; + const node = configuredNode ?? (typeof requestedNode === "string" ? requestedNode.trim() : ""); + return { + ...rest, + host: "node", + ...(node ? { node } : {}), + }; +} + +function hideNodeExecDynamicToolParameters( + parameters: OpenClawDynamicTool["parameters"], + options: { hideNode: boolean }, +) { + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + return parameters; + } + const schema = parameters as Record; + const rawProperties = schema.properties; + if (!rawProperties || typeof rawProperties !== "object" || Array.isArray(rawProperties)) { + return parameters; + } + const nextProperties = Object.fromEntries( + Object.entries(rawProperties).filter( + ([name]) => + !CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && + !(options.hideNode && normalizeCodexDynamicToolName(name) === "node"), + ), + ); + const rawRequired = schema.required; + const nextRequired = Array.isArray(rawRequired) + ? rawRequired.filter( + (name) => + typeof name !== "string" || + (!CODEX_NODE_EXEC_POLICY_PARAMETER_NAMES.has(normalizeCodexDynamicToolName(name)) && + !(options.hideNode && normalizeCodexDynamicToolName(name) === "node")), + ) + : rawRequired; + return { + ...schema, + properties: nextProperties, + ...(Array.isArray(rawRequired) ? { required: nextRequired } : {}), + }; +} diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index b960df30f6c7..68c25cd3122a 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -1,4 +1,3 @@ -// Codex plugin module implements side question behavior. import { randomUUID } from "node:crypto"; import { buildAgentHookContextChannelFields, @@ -253,8 +252,7 @@ export async function runCodexAppServerSideQuestion( const appServer = connection.appServer; const cwd = binding.cwd || params.workspaceDir || process.cwd(); const runId = params.opts?.runId ?? randomUUID(); - // A supervised side run inherits capability facts from the private binding. - // Outer model metadata may describe another provider or disable tools entirely. + // Side runs inherit private-binding capabilities, not outer model metadata. const effectiveParams: AgentHarnessSideQuestionParams = supervisionModelSelection ? { ...params,