From 42aaf0c98a7cd8b8e0fa3413e71a0b1307271899 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 1 May 2026 12:36:17 +0100 Subject: [PATCH] Prefer Codex native workspace tools (#75308) Summary: - The PR adds Codex dynamic-tool profile config defaulting to `native-first`, filters duplicate workspace/process/planning tools from Codex app-server thread payloads, keeps managed `web_search`, updates docs/manifest/config baselines/changelog, and adds regression tests. ClawSweeper fixups: - Included follow-up commit: test(codex): pin native-first tool catalog - Included follow-up commit: chore(config): refresh generated schema baseline - Included follow-up commit: chore: add codex native-first changelog - Included follow-up commit: chore: move native-first changelog entry - Included follow-up commit: chore: refresh config baseline after rebase Validation: - ClawSweeper review passed for head 30e5cecfb7ecf3731b34fa237b79021e5bf50c04. - Required merge gates passed before the squash merge. Prepared head SHA: 30e5cecfb7ecf3731b34fa237b79021e5bf50c04 Review: https://github.com/openclaw/openclaw/pull/75308#issuecomment-4356919781 Co-authored-by: Peter Steinberger Co-authored-by: pashpashpash --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- docs/plugins/codex-harness.md | 13 +++ extensions/codex/openclaw.plugin.json | 20 ++++ .../codex/src/app-server/config.test.ts | 12 +++ extensions/codex/src/app-server/config.ts | 6 ++ .../codex/src/app-server/run-attempt.test.ts | 102 ++++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 45 +++++++- .../codex/src/app-server/thread-lifecycle.ts | 2 +- .../pi-tools.model-provider-collision.test.ts | 21 ++++ src/agents/pi-tools.ts | 5 + 11 files changed, 225 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c859bd15fba..d47a5398177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok. - BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris. - CLI/proxy: add `openclaw proxy validate` so operators can verify effective proxy configuration, proxy reachability, and expected allow/deny destination behavior before deploying proxy-routed OpenClaw commands. (#73438) Thanks @jesse-merhi. +- Agents/Codex: default Codex app-server dynamic tools to native-first, keeping OpenClaw integration tools while leaving file, patch, exec, and process ownership to the Codex harness. (#75308) Thanks @pashpashpash. ### Fixes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index d434cae78d5..f27ae45fd27 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -2197c0110a367c9e2adba959ff8529edad7b4d526894eec602e47189d6930d2f config-baseline.json +1deb67d0a40456e77cb67685f6ae2f14a8ddc2c4be488d4b1a1f1127598982dd config-baseline.json ac7537ed5b5a2d9e7fa50977aa99f5e0babfbe1a93c7c14b93a184b36bb4f539 config-baseline.core.json f3326cd9490169afefe93625f63699266b75db93855ed439c9692e3c286a990c config-baseline.channel.json -4d017161b4dc986fdc6cc68167fedbd1d415ddbcd66125a872e18aa1769cd182 config-baseline.plugin.json +7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json 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 fb556b34abc..f531427c2f9 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -304,6 +304,20 @@ function createMessageDynamicTool( }; } +function createNamedDynamicTool( + name: string, +): Parameters[0]["dynamicTools"][number] { + return { + name, + description: `${name} test tool`, + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }; +} + function extractRelayIdFromThreadRequest(params: unknown): string { const command = ( params as { @@ -335,6 +349,94 @@ 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("starts Codex threads without duplicate OpenClaw workspace tools by default", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string, _params: unknown) => { + if (method === "thread/start") { + return threadStartResult(); + } + throw new Error(`unexpected method: ${method}`); + }); + const dynamicTools = __testing.applyCodexDynamicToolProfile( + [ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", + "web_search", + "message", + ].map(createNamedDynamicTool), + {}, + ); + + await startOrResumeThread({ + client: { request } as never, + params: createParams(sessionFile, workspaceDir), + cwd: workspaceDir, + dynamicTools, + appServer, + }); + + const startRequest = request.mock.calls.find(([method]) => method === "thread/start"); + const dynamicToolNames = ( + (startRequest?.[1] as { dynamicTools?: Array<{ name: string }> } | undefined)?.dynamicTools ?? + [] + ).map((tool) => tool.name); + + expect(dynamicToolNames).toContain("message"); + expect(dynamicToolNames).toContain("web_search"); + expect(dynamicToolNames).not.toEqual( + expect.arrayContaining([ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", + ]), + ); + }); + 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 1015b9f963c..35009a31924 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; const LOG_FIELD_MAX_LENGTH = 160; type OpenClawCodingToolsOptions = NonNullable< @@ -319,7 +332,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; @@ -369,6 +383,7 @@ export async function runCodexAppServerAttempt( sandbox, runAbortController, sessionAgentId, + pluginConfig, onYieldDetected: () => { yieldDetected = true; }, @@ -1317,6 +1332,7 @@ type DynamicToolBuildParams = { sandbox: Awaited>; runAbortController: AbortController; sessionAgentId: string | undefined; + pluginConfig: CodexPluginConfig; onYieldDetected: () => void; }; @@ -1372,6 +1388,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, @@ -1390,7 +1407,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, }); @@ -1411,6 +1429,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; @@ -1584,6 +1622,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/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 0f0bd6a9968..a60fdf89299 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -222,7 +222,7 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { const promptOverlay = renderCodexRuntimePromptOverlay(params); const sections = [ - "You are running inside OpenClaw. Use OpenClaw dynamic tools for messaging, cron, sessions, and host actions when available.", + "You are running inside OpenClaw. Use OpenClaw dynamic tools for OpenClaw-specific integrations such as messaging, cron, sessions, media, gateway, and nodes when available.", "Preserve the user's existing channel/session context. If sending a channel reply, use the OpenClaw messaging tool instead of describing that you would reply.", promptOverlay, params.extraSystemPrompt, 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;