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 30e5cecfb7.
- Required merge gates passed before the squash merge.

Prepared head SHA: 30e5cecfb7
Review: https://github.com/openclaw/openclaw/pull/75308#issuecomment-4356919781

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: pashpashpash <nik@vault77.ai>
This commit is contained in:
Peter Steinberger
2026-05-01 12:36:17 +01:00
committed by GitHub
parent ec69c07b27
commit 42aaf0c98a
11 changed files with 225 additions and 6 deletions

View File

@@ -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/<guid>`) 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

View File

@@ -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

View File

@@ -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 |

View File

@@ -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."

View File

@@ -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({

View File

@@ -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(),

View File

@@ -304,6 +304,20 @@ function createMessageDynamicTool(
};
}
function createNamedDynamicTool(
name: string,
): Parameters<typeof startOrResumeThread>[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;

View File

@@ -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<EmbeddedRunAttemptResult> {
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<ReturnType<typeof resolveSandboxContext>>;
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<T extends { name: string }>(
tools: T[],
config: CodexPluginConfig,
): T[] {
const excludes = new Set<string>();
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<T>(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) => {

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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;