diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 42a33ec5ab2..27daed56e81 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -579,6 +579,19 @@ If a deployment needs additional environment isolation, add those variables to `appServer.clearEnv` only affects the spawned Codex app-server child process. +Codex dynamic tools default to the `native-first` profile. In that mode, +OpenClaw does not expose dynamic tools that duplicate Codex-native workspace +operations: `read`, `write`, `edit`, `apply_patch`, `exec`, `process`, and +`update_plan`. OpenClaw integration tools such as messaging, sessions, media, +cron, browser, nodes, gateway, and `web_search` remain available. + +Supported top-level Codex plugin fields: + +| Field | Default | Meaning | +| -------------------------- | ---------------- | ----------------------------------------------------------------------------------------- | +| `codexDynamicToolsProfile` | `"native-first"` | Use `"openclaw-compat"` to expose the full OpenClaw dynamic tool set to Codex app-server. | +| `codexDynamicToolsExclude` | `[]` | Additional OpenClaw dynamic tool names to omit from Codex app-server turns. | + Supported `appServer` fields: | Field | Default | Meaning | diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 5afd918d581..aaa8132d77f 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -33,6 +33,16 @@ "type": "object", "additionalProperties": false, "properties": { + "codexDynamicToolsProfile": { + "type": "string", + "enum": ["native-first", "openclaw-compat"], + "default": "native-first" + }, + "codexDynamicToolsExclude": { + "type": "array", + "items": { "type": "string" }, + "default": [] + }, "discovery": { "type": "object", "additionalProperties": false, @@ -141,6 +151,16 @@ } }, "uiHints": { + "codexDynamicToolsProfile": { + "label": "Dynamic Tools Profile", + "help": "Select which OpenClaw dynamic tools are exposed to Codex app-server. native-first omits tools Codex already owns.", + "advanced": true + }, + "codexDynamicToolsExclude": { + "label": "Dynamic Tool Excludes", + "help": "Additional OpenClaw dynamic tool names to omit from Codex app-server turns.", + "advanced": true + }, "discovery": { "label": "Model Discovery", "help": "Plugin-owned controls for discovering Codex app-server models." diff --git a/extensions/codex/src/app-server/config.test.ts b/extensions/codex/src/app-server/config.test.ts index 7398ab6f6f3..681f1ebaced 100644 --- a/extensions/codex/src/app-server/config.test.ts +++ b/extensions/codex/src/app-server/config.test.ts @@ -138,6 +138,18 @@ describe("Codex app-server config", () => { ); }); + it("parses dynamic tool profile controls", () => { + expect( + readCodexPluginConfig({ + codexDynamicToolsProfile: "openclaw-compat", + codexDynamicToolsExclude: ["custom_tool"], + }), + ).toMatchObject({ + codexDynamicToolsProfile: "openclaw-compat", + codexDynamicToolsExclude: ["custom_tool"], + }); + }); + it("treats configured and environment commands as explicit overrides", () => { expect( resolveCodexAppServerRuntimeOptions({ diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index a039ccf625f..c8ed14d9e71 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -10,6 +10,7 @@ export type CodexAppServerApprovalPolicy = "never" | "on-request" | "on-failure" export type CodexAppServerSandboxMode = "read-only" | "workspace-write" | "danger-full-access"; export type CodexAppServerApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; export type CodexAppServerCommandSource = "managed" | "resolved-managed" | "config" | "env"; +export type CodexDynamicToolsProfile = "native-first" | "openclaw-compat"; export type CodexComputerUseConfig = { enabled?: boolean; @@ -55,6 +56,8 @@ export type CodexAppServerRuntimeOptions = { }; export type CodexPluginConfig = { + codexDynamicToolsProfile?: CodexDynamicToolsProfile; + codexDynamicToolsExclude?: string[]; discovery?: { enabled?: boolean; timeoutMs?: number; @@ -120,6 +123,7 @@ const codexAppServerApprovalPolicySchema = z.enum([ ]); const codexAppServerSandboxSchema = z.enum(["read-only", "workspace-write", "danger-full-access"]); const codexAppServerApprovalsReviewerSchema = z.enum(["user", "auto_review", "guardian_subagent"]); +const codexDynamicToolsProfileSchema = z.enum(["native-first", "openclaw-compat"]); const codexAppServerServiceTierSchema = z .preprocess( (value) => (value === null ? null : resolveServiceTier(value)), @@ -129,6 +133,8 @@ const codexAppServerServiceTierSchema = z const codexPluginConfigSchema = z .object({ + codexDynamicToolsProfile: codexDynamicToolsProfileSchema.optional(), + codexDynamicToolsExclude: z.array(z.string()).optional(), discovery: z .object({ enabled: z.boolean().optional(), diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index dc6ceccdf3f..15aa9ccb580 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -334,6 +334,40 @@ describe("runCodexAppServerAttempt", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); + it("defaults Codex dynamic tools to the native-first profile", () => { + const tools = [ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", + "web_search", + "message", + "sessions_spawn", + ].map((name) => ({ name })); + + expect(__testing.applyCodexDynamicToolProfile(tools, {}).map((tool) => tool.name)).toEqual([ + "web_search", + "message", + "sessions_spawn", + ]); + }); + + it("allows Codex dynamic tool filtering to opt back into OpenClaw compatibility", () => { + const tools = ["read", "exec", "message", "custom_tool"].map((name) => ({ name })); + + expect( + __testing + .applyCodexDynamicToolProfile(tools, { + codexDynamicToolsProfile: "openclaw-compat", + codexDynamicToolsExclude: ["custom_tool"], + }) + .map((tool) => tool.name), + ).toEqual(["read", "exec", "message"]); + }); + it("returns a failed dynamic tool response when an app-server tool call exceeds the deadline", async () => { vi.useFakeTimers(); let capturedSignal: AbortSignal | undefined; diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index fff8a52703b..21f841ea7e8 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -44,7 +44,11 @@ import { } from "./client-factory.js"; import { isCodexAppServerApprovalRequest, type CodexAppServerClient } from "./client.js"; import { ensureCodexComputerUse } from "./computer-use.js"; -import { resolveCodexAppServerRuntimeOptions } from "./config.js"; +import { + readCodexPluginConfig, + resolveCodexAppServerRuntimeOptions, + type CodexPluginConfig, +} from "./config.js"; import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js"; import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js"; @@ -89,6 +93,15 @@ const CODEX_DYNAMIC_TOOL_TIMEOUT_MS = 30_000; const CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS = 60_000; const CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS = 30 * 60_000; const CODEX_STEER_ALL_DEBOUNCE_MS = 500; +const CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES = [ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", +] as const; type OpenClawCodingToolsOptions = NonNullable< Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0] @@ -231,7 +244,8 @@ export async function runCodexAppServerAttempt( } = {}, ): Promise { const attemptStartedAt = Date.now(); - const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig }); + const pluginConfig = readCodexPluginConfig(options.pluginConfig); + const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig }); const resolvedWorkspace = resolveUserPath(params.workspaceDir); await fs.mkdir(resolvedWorkspace, { recursive: true }); const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId; @@ -281,6 +295,7 @@ export async function runCodexAppServerAttempt( sandbox, runAbortController, sessionAgentId, + pluginConfig, onYieldDetected: () => { yieldDetected = true; }, @@ -1232,6 +1247,7 @@ type DynamicToolBuildParams = { sandbox: Awaited>; runAbortController: AbortController; sessionAgentId: string | undefined; + pluginConfig: CodexPluginConfig; onYieldDetected: () => void; }; @@ -1287,6 +1303,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { modelAuthMode: resolveModelAuthMode(params.model.provider, params.config, undefined, { workspaceDir: input.effectiveWorkspace, }), + suppressManagedWebSearch: false, currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, currentMessageId: params.currentMessageId, @@ -1305,7 +1322,8 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { input.runAbortController.abort("sessions_yield"); }, }); - const visionFilteredTools = filterToolsForVisionInputs(allTools, { + const profiledTools = applyCodexDynamicToolProfile(allTools, input.pluginConfig); + const visionFilteredTools = filterToolsForVisionInputs(profiledTools, { modelHasVision, hasInboundImages: (params.images?.length ?? 0) > 0, }); @@ -1326,6 +1344,26 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { }); } +function applyCodexDynamicToolProfile( + tools: T[], + config: CodexPluginConfig, +): T[] { + const excludes = new Set(); + const profile = config.codexDynamicToolsProfile ?? "native-first"; + if (profile === "native-first") { + for (const name of CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES) { + excludes.add(name); + } + } + for (const name of config.codexDynamicToolsExclude ?? []) { + const trimmed = name.trim(); + if (trimmed) { + excludes.add(trimmed); + } + } + return excludes.size === 0 ? tools : tools.filter((tool) => !excludes.has(tool.name)); +} + async function withCodexStartupTimeout(params: { timeoutMs: number; timeoutFloorMs?: number; @@ -1499,6 +1537,7 @@ export const __testing = { CODEX_TURN_COMPLETION_IDLE_TIMEOUT_MS, CODEX_TURN_TERMINAL_IDLE_TIMEOUT_MS, buildCodexNativeHookRelayId, + applyCodexDynamicToolProfile, filterToolsForVisionInputs, handleDynamicToolCallWithTimeout, ...createCodexAppServerClientFactoryTestHooks((factory) => { diff --git a/src/agents/pi-tools.model-provider-collision.test.ts b/src/agents/pi-tools.model-provider-collision.test.ts index e29b482f657..e491db6d4a1 100644 --- a/src/agents/pi-tools.model-provider-collision.test.ts +++ b/src/agents/pi-tools.model-provider-collision.test.ts @@ -68,6 +68,27 @@ describe("applyModelProviderToolPolicy", () => { expect(toolNames(filtered)).toEqual(["read", "exec"]); }); + it("can keep managed web_search for Codex app-server dynamic tools", () => { + const filtered = __testing.applyModelProviderToolPolicy(baseTools, { + config: { + tools: { + web: { + search: { + enabled: true, + openaiCodex: { enabled: true, mode: "cached" }, + }, + }, + }, + }, + modelProvider: "gateway", + modelApi: "openai-codex-responses", + modelId: "gpt-5.4", + suppressManagedWebSearch: false, + }); + + expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]); + }); + it("removes managed web_search for direct Codex models when auth is available", () => { const filtered = __testing.applyModelProviderToolPolicy(baseTools, { config: { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 793f948ea59..4ab0bcf6164 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -143,6 +143,7 @@ function applyModelProviderToolPolicy( modelId?: string; agentDir?: string; modelCompat?: ModelCompatConfig; + suppressManagedWebSearch?: boolean; }, ): AnyAgentTool[] { if (params?.config?.agents?.defaults?.experimental?.localModelLean === true) { @@ -151,6 +152,7 @@ function applyModelProviderToolPolicy( } if ( + params?.suppressManagedWebSearch !== false && shouldSuppressManagedWebSearchTool({ config: params?.config, modelProvider: params?.modelProvider, @@ -302,6 +304,8 @@ export function createOpenClawCodingTools(options?: { modelContextWindowTokens?: number; /** Resolved runtime model compatibility hints. */ modelCompat?: ModelCompatConfig; + /** If false, keep OpenClaw web_search even when a provider-native search tool is active. */ + suppressManagedWebSearch?: boolean; /** * Auth mode for the current provider. We only need this for Anthropic OAuth * tool-name blocking quirks. @@ -685,6 +689,7 @@ export function createOpenClawCodingTools(options?: { modelId: options?.modelId, agentDir: options?.agentDir, modelCompat: options?.modelCompat, + suppressManagedWebSearch: options?.suppressManagedWebSearch, }); // Security: treat unknown/undefined as unauthorized (opt-in, not opt-out) const senderIsOwner = options?.senderIsOwner === true;