Codex: add native web search for embedded Pi runs

This commit is contained in:
Christof Salis 2026-03-15 11:20:05 +01:00
parent c30cabcca4
commit 21270f900b
25 changed files with 1523 additions and 94 deletions

View File

@ -55,6 +55,41 @@ Runtime SecretRef behavior:
- In auto-detect mode, OpenClaw resolves only the selected provider key. Non-selected provider SecretRefs stay inactive until selected.
- If the selected provider SecretRef is unresolved and no provider env fallback exists, startup/reload fails fast.
## Native Codex web search
Codex-capable models can optionally use the provider-native Responses `web_search` tool instead of OpenClaw's managed `web_search` function.
- Configure it under `tools.web.search.openaiCodex`
- It only activates for Codex-capable models (`openai-codex/*` or providers using `api: "openai-codex-responses"`)
- Managed `web_search` still applies to non-Codex models
- `mode: "cached"` is the default and recommended setting
- `tools.web.search.enabled: false` disables both managed and native search
```json5
{
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
allowedDomains: ["example.com"],
contextSize: "high",
userLocation: {
country: "US",
city: "New York",
timezone: "America/New_York",
},
},
},
},
},
}
```
If native Codex search is enabled but the current model is not Codex-capable, OpenClaw keeps the normal managed `web_search` behavior.
## Setting up web search
Use `openclaw configure --section web` to set up your API key and choose a provider.

View File

@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
buildCodexNativeWebSearchTool,
patchCodexNativeWebSearchPayload,
resolveCodexNativeSearchActivation,
resolveCodexNativeWebSearchConfig,
shouldSuppressManagedWebSearchTool,
} from "./codex-native-web-search.js";
const baseConfig = {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
},
},
},
},
} as const;
describe("resolveCodexNativeSearchActivation", () => {
it("returns managed_only when native Codex search is disabled", () => {
const result = resolveCodexNativeSearchActivation({
config: { tools: { web: { search: { enabled: true } } } },
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
});
expect(result.state).toBe("managed_only");
expect(result.inactiveReason).toBe("codex_not_enabled");
});
it("returns managed_only for non-eligible models", () => {
const result = resolveCodexNativeSearchActivation({
config: baseConfig,
modelProvider: "openai",
modelApi: "openai-responses",
});
expect(result.state).toBe("managed_only");
expect(result.inactiveReason).toBe("model_not_eligible");
});
it("activates for direct openai-codex when auth exists", () => {
const result = resolveCodexNativeSearchActivation({
config: {
...baseConfig,
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
},
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
});
expect(result.state).toBe("native_active");
expect(result.codexMode).toBe("cached");
});
it("falls back to managed_only when direct openai-codex auth is missing", () => {
const result = resolveCodexNativeSearchActivation({
config: baseConfig,
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
});
expect(result.state).toBe("managed_only");
expect(result.inactiveReason).toBe("codex_auth_missing");
});
it("activates for api-compatible openai-codex-responses providers without separate Codex auth", () => {
const result = resolveCodexNativeSearchActivation({
config: baseConfig,
modelProvider: "gateway",
modelApi: "openai-codex-responses",
});
expect(result.state).toBe("native_active");
});
it("keeps all search disabled when global web search is disabled", () => {
const result = resolveCodexNativeSearchActivation({
config: {
tools: {
web: {
search: {
enabled: false,
openaiCodex: { enabled: true, mode: "live" },
},
},
},
},
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
});
expect(result.state).toBe("managed_only");
expect(result.inactiveReason).toBe("globally_disabled");
});
});
describe("Codex native web-search payload helpers", () => {
it("normalizes optional config values", () => {
const result = resolveCodexNativeWebSearchConfig({
tools: {
web: {
search: {
openaiCodex: {
enabled: true,
allowedDomains: [" example.com ", "example.com", ""],
contextSize: "high",
userLocation: {
country: " US ",
city: " New York ",
timezone: "America/New_York",
},
},
},
},
},
});
expect(result).toMatchObject({
enabled: true,
mode: "cached",
allowedDomains: ["example.com"],
contextSize: "high",
userLocation: {
country: "US",
city: "New York",
timezone: "America/New_York",
},
});
});
it("builds the native Responses web_search tool", () => {
expect(
buildCodexNativeWebSearchTool({
tools: {
web: {
search: {
openaiCodex: {
enabled: true,
mode: "live",
allowedDomains: ["example.com"],
contextSize: "medium",
userLocation: { country: "US" },
},
},
},
},
}),
).toEqual({
type: "web_search",
external_web_access: true,
filters: { allowed_domains: ["example.com"] },
search_context_size: "medium",
user_location: {
type: "approximate",
country: "US",
},
});
});
it("injects native web_search into provider payloads", () => {
const payload: Record<string, unknown> = { tools: [{ type: "function", name: "read" }] };
const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig });
expect(result.status).toBe("injected");
expect(payload.tools).toEqual([
{ type: "function", name: "read" },
{ type: "web_search", external_web_access: false },
]);
});
it("does not inject a duplicate native web_search tool", () => {
const payload: Record<string, unknown> = { tools: [{ type: "web_search" }] };
const result = patchCodexNativeWebSearchPayload({ payload, config: baseConfig });
expect(result.status).toBe("native_tool_already_present");
expect(payload.tools).toEqual([{ type: "web_search" }]);
});
});
describe("shouldSuppressManagedWebSearchTool", () => {
it("suppresses managed web_search only when native Codex search is active", () => {
expect(
shouldSuppressManagedWebSearchTool({
config: baseConfig,
modelProvider: "gateway",
modelApi: "openai-codex-responses",
}),
).toBe(true);
expect(
shouldSuppressManagedWebSearchTool({
config: baseConfig,
modelProvider: "openai",
modelApi: "openai-responses",
}),
).toBe(false);
});
});

View File

@ -0,0 +1,300 @@
import type { OpenClawConfig } from "../config/config.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveDefaultModelForAgent } from "./model-selection.js";
export type CodexNativeSearchMode = "cached" | "live";
export type CodexNativeSearchContextSize = "low" | "medium" | "high";
export type CodexNativeSearchUserLocation = {
country?: string;
region?: string;
city?: string;
timezone?: string;
};
export type ResolvedCodexNativeWebSearchConfig = {
enabled: boolean;
mode: CodexNativeSearchMode;
allowedDomains?: string[];
contextSize?: CodexNativeSearchContextSize;
userLocation?: CodexNativeSearchUserLocation;
};
export type CodexNativeSearchActivation = {
globalWebSearchEnabled: boolean;
codexNativeEnabled: boolean;
codexMode: CodexNativeSearchMode;
nativeEligible: boolean;
hasRequiredAuth: boolean;
state: "managed_only" | "native_active";
inactiveReason?:
| "globally_disabled"
| "codex_not_enabled"
| "model_not_eligible"
| "codex_auth_missing";
};
export type CodexNativeSearchPayloadPatchResult = {
status: "payload_not_object" | "native_tool_already_present" | "injected";
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function trimToUndefined(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeAllowedDomains(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const deduped = [
...new Set(
value
.map((entry) => trimToUndefined(entry))
.filter((entry): entry is string => typeof entry === "string"),
),
];
return deduped.length > 0 ? deduped : undefined;
}
function normalizeContextSize(value: unknown): CodexNativeSearchContextSize | undefined {
if (value === "low" || value === "medium" || value === "high") {
return value;
}
return undefined;
}
function normalizeMode(value: unknown): CodexNativeSearchMode {
return value === "live" ? "live" : "cached";
}
function normalizeUserLocation(value: unknown): CodexNativeSearchUserLocation | undefined {
if (!isRecord(value)) {
return undefined;
}
const location = {
country: trimToUndefined(value.country),
region: trimToUndefined(value.region),
city: trimToUndefined(value.city),
timezone: trimToUndefined(value.timezone),
};
return location.country || location.region || location.city || location.timezone
? location
: undefined;
}
export function resolveCodexNativeWebSearchConfig(
config: OpenClawConfig | undefined,
): ResolvedCodexNativeWebSearchConfig {
const nativeConfig = config?.tools?.web?.search?.openaiCodex;
return {
enabled: nativeConfig?.enabled === true,
mode: normalizeMode(nativeConfig?.mode),
allowedDomains: normalizeAllowedDomains(nativeConfig?.allowedDomains),
contextSize: normalizeContextSize(nativeConfig?.contextSize),
userLocation: normalizeUserLocation(nativeConfig?.userLocation),
};
}
export function isCodexNativeSearchEligibleModel(params: {
modelProvider?: string;
modelApi?: string;
}): boolean {
return params.modelProvider === "openai-codex" || params.modelApi === "openai-codex-responses";
}
export function hasCodexNativeWebSearchTool(tools: unknown): boolean {
if (!Array.isArray(tools)) {
return false;
}
return tools.some(
(tool) => isRecord(tool) && typeof tool.type === "string" && tool.type === "web_search",
);
}
export function hasAvailableCodexAuth(params: {
config?: OpenClawConfig;
agentDir?: string;
}): boolean {
if (params.agentDir) {
try {
if (
listProfilesForProvider(ensureAuthProfileStore(params.agentDir), "openai-codex").length > 0
) {
return true;
}
} catch {
// Fall back to config-based detection below.
}
}
return Object.values(params.config?.auth?.profiles ?? {}).some(
(profile) => isRecord(profile) && profile.provider === "openai-codex",
);
}
export function resolveCodexNativeSearchActivation(params: {
config?: OpenClawConfig;
modelProvider?: string;
modelApi?: string;
agentDir?: string;
}): CodexNativeSearchActivation {
const globalWebSearchEnabled = params.config?.tools?.web?.search?.enabled !== false;
const codexConfig = resolveCodexNativeWebSearchConfig(params.config);
const nativeEligible = isCodexNativeSearchEligibleModel(params);
const hasRequiredAuth = params.modelProvider !== "openai-codex" || hasAvailableCodexAuth(params);
if (!globalWebSearchEnabled) {
return {
globalWebSearchEnabled,
codexNativeEnabled: codexConfig.enabled,
codexMode: codexConfig.mode,
nativeEligible,
hasRequiredAuth,
state: "managed_only",
inactiveReason: "globally_disabled",
};
}
if (!codexConfig.enabled) {
return {
globalWebSearchEnabled,
codexNativeEnabled: false,
codexMode: codexConfig.mode,
nativeEligible,
hasRequiredAuth,
state: "managed_only",
inactiveReason: "codex_not_enabled",
};
}
if (!nativeEligible) {
return {
globalWebSearchEnabled,
codexNativeEnabled: true,
codexMode: codexConfig.mode,
nativeEligible: false,
hasRequiredAuth,
state: "managed_only",
inactiveReason: "model_not_eligible",
};
}
if (!hasRequiredAuth) {
return {
globalWebSearchEnabled,
codexNativeEnabled: true,
codexMode: codexConfig.mode,
nativeEligible: true,
hasRequiredAuth: false,
state: "managed_only",
inactiveReason: "codex_auth_missing",
};
}
return {
globalWebSearchEnabled,
codexNativeEnabled: true,
codexMode: codexConfig.mode,
nativeEligible: true,
hasRequiredAuth: true,
state: "native_active",
};
}
export function buildCodexNativeWebSearchTool(
config: OpenClawConfig | undefined,
): Record<string, unknown> {
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
const tool: Record<string, unknown> = {
type: "web_search",
external_web_access: nativeConfig.mode === "live",
};
if (nativeConfig.allowedDomains) {
tool.filters = {
allowed_domains: nativeConfig.allowedDomains,
};
}
if (nativeConfig.contextSize) {
tool.search_context_size = nativeConfig.contextSize;
}
if (nativeConfig.userLocation) {
tool.user_location = {
type: "approximate",
...nativeConfig.userLocation,
};
}
return tool;
}
export function patchCodexNativeWebSearchPayload(params: {
payload: unknown;
config?: OpenClawConfig;
}): CodexNativeSearchPayloadPatchResult {
if (!isRecord(params.payload)) {
return { status: "payload_not_object" };
}
const payload = params.payload;
if (hasCodexNativeWebSearchTool(payload.tools)) {
return { status: "native_tool_already_present" };
}
const tools = Array.isArray(payload.tools) ? [...payload.tools] : [];
tools.push(buildCodexNativeWebSearchTool(params.config));
payload.tools = tools;
return { status: "injected" };
}
export function shouldSuppressManagedWebSearchTool(params: {
config?: OpenClawConfig;
modelProvider?: string;
modelApi?: string;
agentDir?: string;
}): boolean {
return resolveCodexNativeSearchActivation(params).state === "native_active";
}
export function isCodexNativeWebSearchRelevant(params: {
config: OpenClawConfig;
agentId?: string;
agentDir?: string;
}): boolean {
if (resolveCodexNativeWebSearchConfig(params.config).enabled) {
return true;
}
if (hasAvailableCodexAuth(params)) {
return true;
}
const defaultModel = resolveDefaultModelForAgent({
cfg: params.config,
agentId: params.agentId,
});
const configuredProvider = params.config.models?.providers?.[defaultModel.provider];
return isCodexNativeSearchEligibleModel({
modelProvider: defaultModel.provider,
modelApi: configuredProvider?.api,
});
}
export function describeCodexNativeWebSearch(
config: OpenClawConfig | undefined,
): string | undefined {
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
if (!nativeConfig.enabled) {
return undefined;
}
return `Codex native search: ${nativeConfig.mode} for Codex-capable models`;
}

View File

@ -1193,6 +1193,98 @@ describe("applyExtraParamsToAgent", () => {
expect(calls[0]?.openaiWsWarmup).toBe(false);
});
it("injects native Codex web_search for api-compatible Responses models", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "gateway",
applyModelId: "gpt-5.4",
cfg: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "live",
allowedDomains: ["example.com"],
},
},
},
},
},
model: {
api: "openai-codex-responses",
provider: "gateway",
id: "gpt-5.4",
} as Model<"openai-codex-responses">,
payload: { tools: [{ type: "function", name: "read" }] },
});
expect(payload.tools).toEqual([
{ type: "function", name: "read" },
{
type: "web_search",
external_web_access: true,
filters: { allowed_domains: ["example.com"] },
},
]);
});
it("does not inject duplicate native Codex web_search tools", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "gateway",
applyModelId: "gpt-5.4",
cfg: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
},
},
},
},
},
model: {
api: "openai-codex-responses",
provider: "gateway",
id: "gpt-5.4",
} as Model<"openai-codex-responses">,
payload: { tools: [{ type: "web_search" }] },
});
expect(payload.tools).toEqual([{ type: "web_search" }]);
});
it("keeps payload unchanged when Codex native search is inactive", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
cfg: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
},
},
},
},
},
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as Model<"openai-responses">,
payload: { tools: [{ type: "function", name: "read" }] },
});
expect(payload.tools).toEqual([{ type: "function", name: "read" }]);
});
it("lets runtime options override OpenAI default transport", () => {
const { calls, agent } = createOptionsCaptureAgent();

View File

@ -552,6 +552,7 @@ export async function compactEmbeddedPiSessionDirect(
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelApi: model.api,
modelContextWindowTokens: ctxInfo.tokens,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});

View File

@ -23,6 +23,7 @@ import {
} from "./moonshot-stream-wrappers.js";
import {
createCodexDefaultTransportWrapper,
createCodexNativeWebSearchWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIFastModeWrapper,
createOpenAIResponsesContextManagementWrapper,
@ -335,6 +336,7 @@ export function applyExtraParamsToAgent(
extraParamsOverride?: Record<string, unknown>,
thinkingLevel?: ThinkLevel,
agentId?: string,
agentDir?: string,
): void {
const resolvedExtraParams = resolveExtraParams({
cfg,
@ -459,6 +461,11 @@ export function applyExtraParamsToAgent(
agent.streamFn = createOpenAIServiceTierWrapper(agent.streamFn, openAIServiceTier);
}
agent.streamFn = createCodexNativeWebSearchWrapper(agent.streamFn, {
config: cfg,
agentDir,
});
// Work around upstream pi-ai hardcoding `store: false` for Responses API.
// Force `store=true` for direct OpenAI Responses models and auto-enable
// server-side compaction for compatible OpenAI Responses payloads.

View File

@ -1,6 +1,11 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";
import type { OpenClawConfig } from "../../config/config.js";
import {
patchCodexNativeWebSearchPayload,
resolveCodexNativeSearchActivation,
} from "../codex-native-web-search.js";
import { log } from "./logger.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
@ -334,6 +339,59 @@ export function createOpenAIServiceTierWrapper(
};
}
export function createCodexNativeWebSearchWrapper(
baseStreamFn: StreamFn | undefined,
params: { config?: OpenClawConfig; agentDir?: string },
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const activation = resolveCodexNativeSearchActivation({
config: params.config,
modelProvider: typeof model.provider === "string" ? model.provider : undefined,
modelApi: typeof model.api === "string" ? model.api : undefined,
agentDir: params.agentDir,
});
if (activation.state !== "native_active") {
if (activation.codexNativeEnabled) {
log.debug(
`skipping Codex native web search (${activation.inactiveReason ?? "inactive"}) for ${String(
model.provider ?? "unknown",
)}/${String(model.id ?? "unknown")}`,
);
}
return underlying(model, context, options);
}
log.debug(
`activating Codex native web search (${activation.codexMode}) for ${String(
model.provider ?? "unknown",
)}/${String(model.id ?? "unknown")}`,
);
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
const result = patchCodexNativeWebSearchPayload({
payload,
config: params.config,
});
if (result.status === "payload_not_object") {
log.debug(
"Skipping Codex native web search injection because provider payload is not an object",
);
} else if (result.status === "native_tool_already_present") {
log.debug("Codex native web search tool already present in provider payload");
} else if (result.status === "injected") {
log.debug("Injected Codex native web search tool into provider payload");
}
return originalOnPayload?.(payload, model);
},
});
};
}
export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>

View File

@ -1512,6 +1512,7 @@ export async function runEmbeddedAttempt(
abortSignal: runAbortController.signal,
modelProvider: params.model.provider,
modelId: params.modelId,
modelApi: params.model.api,
modelContextWindowTokens: params.model.contextWindow,
modelAuthMode: resolveModelAuthMode(params.model.provider, params.config),
currentChannelId: params.currentChannelId,
@ -1938,6 +1939,7 @@ export async function runEmbeddedAttempt(
},
params.thinkLevel,
sessionAgentId,
agentDir,
);
if (cacheTrace) {

View File

@ -39,4 +39,72 @@ describe("applyModelProviderToolPolicy", () => {
expect(toolNames(filtered)).toEqual(["read", "exec"]);
});
it("removes managed web_search when native Codex search is active", () => {
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",
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);
});
it("removes managed web_search for direct Codex models when auth is available", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
config: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: { enabled: true, mode: "cached" },
},
},
},
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
},
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
modelId: "gpt-5.4",
});
expect(toolNames(filtered)).toEqual(["read", "exec"]);
});
it("keeps managed web_search when Codex native search cannot activate", () => {
const filtered = __testing.applyModelProviderToolPolicy(baseTools, {
config: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: { enabled: true, mode: "cached" },
},
},
},
},
modelProvider: "openai-codex",
modelApi: "openai-codex-responses",
modelId: "gpt-5.4",
});
expect(toolNames(filtered)).toEqual(["read", "web_search", "exec"]);
});
});

View File

@ -15,6 +15,7 @@ import {
type ProcessToolDefaults,
} from "./bash-tools.js";
import { listChannelAgentTools } from "./channel-tools.js";
import { shouldSuppressManagedWebSearchTool } from "./codex-native-web-search.js";
import { resolveImageSanitizationLimits } from "./image-sanitization.js";
import type { ModelAuthMode } from "./model-auth.js";
import { createOpenClawTools } from "./openclaw-tools.js";
@ -92,14 +93,32 @@ function applyMessageProviderToolPolicy(
function applyModelProviderToolPolicy(
tools: AnyAgentTool[],
params?: { modelProvider?: string; modelId?: string },
params?: {
config?: OpenClawConfig;
modelProvider?: string;
modelApi?: string;
modelId?: string;
agentDir?: string;
},
): AnyAgentTool[] {
if (!isXaiProvider(params?.modelProvider, params?.modelId)) {
return tools;
if (isXaiProvider(params?.modelProvider, params?.modelId)) {
// xAI/Grok providers expose a native web_search tool; sending OpenClaw's
// web_search alongside it causes duplicate-name request failures.
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
}
// xAI/Grok providers expose a native web_search tool; sending OpenClaw's
// web_search alongside it causes duplicate-name request failures.
return tools.filter((tool) => !TOOL_DENY_FOR_XAI_PROVIDERS.has(tool.name));
if (
shouldSuppressManagedWebSearchTool({
config: params?.config,
modelProvider: params?.modelProvider,
modelApi: params?.modelApi,
agentDir: params?.agentDir,
})
) {
return tools.filter((tool) => tool.name !== "web_search");
}
return tools;
}
function isApplyPatchAllowedForModel(params: {
@ -230,6 +249,8 @@ export function createOpenClawCodingTools(options?: {
modelProvider?: string;
/** Model id for the current provider (used for model-specific tool gating). */
modelId?: string;
/** Model API for the current provider (used for provider-native tool arbitration). */
modelApi?: string;
/** Model context window in tokens (used to scale read-tool output budget). */
modelContextWindowTokens?: number;
/**
@ -562,8 +583,11 @@ export function createOpenClawCodingTools(options?: {
options?.messageProvider,
);
const toolsForModelProvider = applyModelProviderToolPolicy(toolsForMessageProvider, {
config: options?.config,
modelProvider: options?.modelProvider,
modelApi: options?.modelApi,
modelId: options?.modelId,
agentDir: options?.agentDir,
});
// Security: treat unknown/undefined as unauthorized (opt-in, not opt-out)
const senderIsOwner = options?.senderIsOwner === true;

View File

@ -115,6 +115,15 @@ export async function applyAuthChoiceOpenAI(
agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL;
await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL);
}
await params.prompter.note(
[
"Codex-capable models can optionally use native Codex web search.",
"Enable it with openclaw configure --section web.",
"Recommended mode: cached.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
return { config: nextConfig, agentModelOverride };
}

View File

@ -164,7 +164,8 @@ describe("applyAuthChoice", () => {
expires: Date.now() + 60_000,
});
const prompter = createPrompter({});
const note = vi.fn(async () => {});
const prompter = createPrompter({ note });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoice({
@ -187,6 +188,10 @@ describe("applyAuthChoice", () => {
access: "access-token",
email: "user@example.com",
});
expect(note).toHaveBeenCalledWith(
expect.stringContaining("native Codex web search"),
"Web search",
);
});
it("prompts and writes provider API key for common providers", async () => {

View File

@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
@ -99,6 +99,11 @@ import { WizardCancelledError } from "../wizard/prompts.js";
import { runConfigureWizard } from "./configure.wizard.js";
describe("runConfigureWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.ensureControlUiAssetsBuilt.mockResolvedValue({ ok: true });
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
@ -158,4 +163,116 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("can enable native Codex search without configuring a managed provider", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("cached");
mocks.clackConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
mocks.clackText.mockResolvedValue("");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.writeConfigFile).toHaveBeenLastCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
enabled: true,
openaiCodex: expect.objectContaining({
enabled: true,
mode: "cached",
}),
}),
}),
}),
}),
);
});
it("persists openaiCodex.enabled=false when the toggle is disabled", async () => {
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: false,
valid: true,
config: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "live",
},
},
},
},
},
issues: [],
});
mocks.resolveGatewayPort.mockReturnValue(18789);
mocks.probeGatewayReachable.mockResolvedValue({ ok: false });
mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" });
mocks.summarizeExistingConfig.mockReturnValue("");
mocks.createClackPrompter.mockReturnValue({});
mocks.clackSelect.mockResolvedValueOnce("local").mockResolvedValueOnce("brave");
mocks.clackConfirm
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
mocks.clackText.mockResolvedValue("");
await runConfigureWizard(
{ command: "configure", sections: ["web"] },
{
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
},
);
expect(mocks.writeConfigFile).toHaveBeenLastCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
web: expect.objectContaining({
search: expect.objectContaining({
enabled: true,
openaiCodex: expect.objectContaining({
enabled: false,
mode: "live",
}),
}),
}),
}),
}),
);
});
});

View File

@ -173,6 +173,8 @@ async function promptWebToolsConfig(
applySearchKey,
hasKeyInEnv,
} = await import("./onboard-search.js");
const { describeCodexNativeWebSearch, isCodexNativeWebSearchRelevant } =
await import("../agents/codex-native-web-search.js");
type SP = (typeof SEARCH_PROVIDER_OPTIONS)[number]["value"];
const hasKeyForProvider = (provider: string): boolean => {
@ -197,7 +199,7 @@ async function promptWebToolsConfig(
note(
[
"Web search lets your agent look things up online using the `web_search` tool.",
"Choose a provider and paste your API key.",
"Choose a managed provider now, and Codex-capable models can also use native Codex web search.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
@ -218,62 +220,137 @@ async function promptWebToolsConfig(
};
if (enableSearch) {
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasKeyForProvider(entry.value);
return {
value: entry.value,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
};
});
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
nextSearch = { ...applied.tools?.web?.search };
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
} else {
const codexRelevant = isCodexNativeWebSearchRelevant({ config: nextConfig });
let configureManagedProvider = true;
if (codexRelevant) {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
"Codex-capable models can optionally use native Codex web search.",
"Managed web_search still controls non-Codex models.",
"If no managed provider is configured, non-Codex models still rely on provider auto-detect and may have no search available.",
...(describeCodexNativeWebSearch(nextConfig)
? [describeCodexNativeWebSearch(nextConfig)!]
: ["Recommended mode: cached."]),
].join("\n"),
"Web search",
"Codex native search",
);
const enableCodexNative = guardCancel(
await confirm({
message: "Enable native Codex web search for Codex-capable models?",
initialValue: existingSearch?.openaiCodex?.enabled === true,
}),
runtime,
);
if (enableCodexNative) {
const codexMode = guardCancel(
await select({
message: "Codex native web search mode",
options: [
{
value: "cached",
label: "cached (recommended)",
hint: "Uses cached web content",
},
{
value: "live",
label: "live",
hint: "Allows live external web access",
},
],
initialValue: existingSearch?.openaiCodex?.mode ?? "cached",
}),
runtime,
);
nextSearch = {
...nextSearch,
openaiCodex: {
...existingSearch?.openaiCodex,
enabled: true,
mode: codexMode,
},
};
configureManagedProvider = guardCancel(
await confirm({
message: "Configure or change a managed web search provider now?",
initialValue: Boolean(existingProvider),
}),
runtime,
);
} else {
nextSearch = {
...nextSearch,
openaiCodex: {
...existingSearch?.openaiCodex,
enabled: false,
},
};
}
}
if (configureManagedProvider) {
const providerOptions = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasKeyForProvider(entry.value);
return {
value: entry.value,
label: entry.label,
hint: configured ? `${entry.hint} · configured` : entry.hint,
};
});
const providerChoice = guardCancel(
await select({
message: "Choose web search provider",
options: providerOptions,
initialValue: existingProvider,
}),
runtime,
);
nextSearch = { ...nextSearch, provider: providerChoice };
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === providerChoice)!;
const existingKey = resolveExistingKey(nextConfig, providerChoice as SP);
const keyConfigured = hasExistingKey(nextConfig, providerChoice as SP);
const envAvailable = entry.envKeys.some((k) => Boolean(process.env[k]?.trim()));
const envVarNames = entry.envKeys.join(" / ");
const keyInput = guardCancel(
await text({
message: keyConfigured
? envAvailable
? `${entry.label} API key (leave blank to keep current or use ${envVarNames})`
: `${entry.label} API key (leave blank to keep current)`
: envAvailable
? `${entry.label} API key (paste it here; leave blank to use ${envVarNames})`
: `${entry.label} API key`,
placeholder: keyConfigured ? "Leave blank to keep current" : entry.placeholder,
}),
runtime,
);
const key = String(keyInput ?? "").trim();
if (key || existingKey) {
const applied = applySearchKey(nextConfig, providerChoice as SP, (key || existingKey)!);
nextSearch = {
...applied.tools?.web?.search,
openaiCodex: {
...existingSearch?.openaiCodex,
...(nextSearch.openaiCodex as Record<string, unknown> | undefined),
},
};
} else if (keyConfigured || envAvailable) {
nextSearch = { ...nextSearch };
} else {
note(
[
"No key stored yet — web_search won't work until a key is available.",
`Store a key here or set ${envVarNames} in the Gateway environment.`,
`Get your API key at: ${entry.signupUrl}`,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
}
}
}

View File

@ -185,6 +185,9 @@ describe("modelsAuthLoginCommand", () => {
expect(runtime.log).toHaveBeenCalledWith(
"Default model available: openai-codex/gpt-5.4 (use --set-default to apply)",
);
expect(runtime.log).toHaveBeenCalledWith(
"Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web",
);
});
it("applies openai-codex default model when --set-default is used", async () => {

View File

@ -340,6 +340,9 @@ async function runBuiltInOpenAICodexLogin(params: {
`Default model available: ${OPENAI_CODEX_DEFAULT_MODEL} (use --set-default to apply)`,
);
}
params.runtime.log(
"Tip: Codex-capable models can use native Codex web search. Enable it with openclaw configure --section web (recommended mode: cached). Docs: https://docs.openclaw.ai/tools/web",
);
}
export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: RuntimeEnv) {

View File

@ -12,11 +12,21 @@ const runtime: RuntimeEnv = {
}) as RuntimeEnv["exit"],
};
function createPrompter(params: { selectValue?: string; textValue?: string }): {
function createPrompter(params: {
selectValue?: string;
selectValues?: string[];
textValue?: string;
textValues?: string[];
confirmValue?: boolean;
confirmValues?: boolean[];
}): {
prompter: WizardPrompter;
notes: Array<{ title?: string; message: string }>;
} {
const notes: Array<{ title?: string; message: string }> = [];
const selectQueue = [...(params.selectValues ?? [])];
const textQueue = [...(params.textValues ?? [])];
const confirmQueue = [...(params.confirmValues ?? [])];
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
@ -24,11 +34,11 @@ function createPrompter(params: { selectValue?: string; textValue?: string }): {
notes.push({ title, message });
}),
select: vi.fn(
async () => params.selectValue ?? "perplexity",
async () => selectQueue.shift() ?? params.selectValue ?? "perplexity",
) as unknown as WizardPrompter["select"],
multiselect: vi.fn(async () => []) as unknown as WizardPrompter["multiselect"],
text: vi.fn(async () => params.textValue ?? ""),
confirm: vi.fn(async () => true),
text: vi.fn(async () => textQueue.shift() ?? params.textValue ?? ""),
confirm: vi.fn(async () => confirmQueue.shift() ?? params.confirmValue ?? true),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
return { prompter, notes };
@ -73,11 +83,12 @@ async function runQuickstartPerplexitySetup(
}
describe("setupSearch", () => {
it("returns config unchanged when user skips", async () => {
it("lets users skip provider setup after enabling web_search", async () => {
const cfg: OpenClawConfig = {};
const { prompter } = createPrompter({ selectValue: "__skip__" });
const result = await setupSearch(cfg, runtime, prompter);
expect(result).toBe(cfg);
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(result.tools?.web?.search?.provider).toBeUndefined();
});
it("sets provider and key for perplexity", async () => {
@ -151,7 +162,7 @@ describe("setupSearch", () => {
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("brave");
expect(result.tools?.web?.search?.enabled).toBeUndefined();
expect(result.tools?.web?.search?.enabled).toBe(true);
const missingNote = notes.find((n) => n.message.includes("No API key stored"));
expect(missingNote).toBeDefined();
} finally {
@ -171,11 +182,13 @@ describe("setupSearch", () => {
expect(result.tools?.web?.search?.enabled).toBe(true);
});
it("advanced preserves enabled:false when keeping existing key", async () => {
const result = await runBlankPerplexityKeyEntry(
it("keeps search disabled when the onboarding toggle is declined", async () => {
const cfg = createPerplexityConfig(
"existing-key", // pragma: allowlist secret
false,
);
const { prompter } = createPrompter({ confirmValue: false });
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("existing-key");
expect(result.tools?.web?.search?.enabled).toBe(false);
});
@ -190,11 +203,15 @@ describe("setupSearch", () => {
expect(prompter.text).not.toHaveBeenCalled();
});
it("quickstart preserves enabled:false when search was intentionally disabled", async () => {
const { result, prompter } = await runQuickstartPerplexitySetup(
it("quickstart keeps search disabled when the onboarding toggle is declined", async () => {
const cfg = createPerplexityConfig(
"stored-pplx-key", // pragma: allowlist secret
false,
);
const { prompter } = createPrompter({ confirmValue: false });
const result = await setupSearch(cfg, runtime, prompter, {
quickstartDefaults: true,
});
expect(result.tools?.web?.search?.provider).toBe("perplexity");
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
expect(result.tools?.web?.search?.enabled).toBe(false);
@ -212,7 +229,7 @@ describe("setupSearch", () => {
});
expect(prompter.text).toHaveBeenCalled();
expect(result.tools?.web?.search?.provider).toBe("grok");
expect(result.tools?.web?.search?.enabled).toBeUndefined();
expect(result.tools?.web?.search?.enabled).toBe(true);
} finally {
if (original === undefined) {
delete process.env.XAI_API_KEY;
@ -283,6 +300,59 @@ describe("setupSearch", () => {
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
});
it("can enable native Codex search without forcing managed provider setup", async () => {
const cfg: OpenClawConfig = {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
};
const { prompter } = createPrompter({
confirmValues: [true, true, false],
selectValues: ["cached"],
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.enabled).toBe(true);
expect(result.tools?.web?.search?.openaiCodex).toEqual({
enabled: true,
mode: "cached",
});
expect(result.tools?.web?.search?.provider).toBeUndefined();
});
it("still supports managed provider setup for Codex users", async () => {
const cfg: OpenClawConfig = {
auth: {
profiles: {
"openai-codex:default": {
provider: "openai-codex",
mode: "oauth",
},
},
},
};
const { prompter } = createPrompter({
confirmValues: [true, true, true],
selectValues: ["live", "brave"],
textValue: "BSA-test-key",
});
const result = await setupSearch(cfg, runtime, prompter);
expect(result.tools?.web?.search?.provider).toBe("brave");
expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key");
expect(result.tools?.web?.search?.openaiCodex).toEqual({
enabled: true,
mode: "live",
});
});
it("exports all 5 providers in SEARCH_PROVIDER_OPTIONS", () => {
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(5);
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);

View File

@ -1,3 +1,7 @@
import {
describeCodexNativeWebSearch,
isCodexNativeWebSearchRelevant,
} from "../agents/codex-native-web-search.js";
import type { OpenClawConfig } from "../config/config.js";
import {
DEFAULT_SECRET_PROVIDER_ALIAS,
@ -184,7 +188,33 @@ function preserveDisabledState(original: OpenClawConfig, result: OpenClawConfig)
};
}
function applyCodexNativeSearchConfig(
config: OpenClawConfig,
params: { enabled: boolean; mode?: "cached" | "live" },
): OpenClawConfig {
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
enabled: params.enabled ? true : config.tools?.web?.search?.enabled,
openaiCodex: {
...config.tools?.web?.search?.openaiCodex,
enabled: params.enabled,
...(params.mode ? { mode: params.mode } : {}),
},
},
},
},
};
}
export type SetupSearchOptions = {
agentId?: string;
agentDir?: string;
quickstartDefaults?: boolean;
secretInputMode?: SecretInputMode;
};
@ -198,16 +228,102 @@ export async function setupSearch(
await prompter.note(
[
"Web search lets your agent look things up online.",
"Choose a provider and paste your API key.",
"You can configure a managed provider now, and Codex-capable models can also use native Codex web search.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
const existingProvider = config.tools?.web?.search?.provider;
const enableSearch = await prompter.confirm({
message: "Enable web_search?",
initialValue: config.tools?.web?.search?.enabled !== false,
});
if (!enableSearch) {
return {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
enabled: false,
},
},
},
};
}
let nextConfig: OpenClawConfig = {
...config,
tools: {
...config.tools,
web: {
...config.tools?.web,
search: {
...config.tools?.web?.search,
enabled: true,
},
},
},
};
const codexRelevant = isCodexNativeWebSearchRelevant({
config: nextConfig,
agentId: opts?.agentId,
agentDir: opts?.agentDir,
});
if (codexRelevant) {
const currentNativeSummary = describeCodexNativeWebSearch(nextConfig);
await prompter.note(
[
"Codex-capable models can optionally use native Codex web search.",
"This does not replace managed web_search for other models.",
"If you skip managed provider setup, non-Codex models still rely on provider auto-detect and may have no search available.",
...(currentNativeSummary ? [currentNativeSummary] : ["Recommended mode: cached."]),
].join("\n"),
"Codex native search",
);
const enableCodexNative = await prompter.confirm({
message: "Enable native Codex web search for Codex-capable models?",
initialValue: config.tools?.web?.search?.openaiCodex?.enabled === true,
});
if (enableCodexNative) {
const codexMode = await prompter.select<"cached" | "live">({
message: "Codex native web search mode",
options: [
{
value: "cached",
label: "cached (recommended)",
hint: "Uses cached web content",
},
{
value: "live",
label: "live",
hint: "Allows live external web access",
},
],
initialValue: config.tools?.web?.search?.openaiCodex?.mode ?? "cached",
});
nextConfig = applyCodexNativeSearchConfig(nextConfig, {
enabled: true,
mode: codexMode,
});
const configureManagedProvider = await prompter.confirm({
message: "Configure a managed web search provider now?",
initialValue: Boolean(config.tools?.web?.search?.provider),
});
if (!configureManagedProvider) {
return nextConfig;
}
} else {
nextConfig = applyCodexNativeSearchConfig(nextConfig, { enabled: false });
}
}
const existingProvider = nextConfig.tools?.web?.search?.provider;
const options = SEARCH_PROVIDER_OPTIONS.map((entry) => {
const configured = hasExistingKey(config, entry.value) || hasKeyInEnv(entry);
const configured = hasExistingKey(nextConfig, entry.value) || hasKeyInEnv(entry);
const hint = configured ? `${entry.hint} · configured` : entry.hint;
return { value: entry.value, label: entry.label, hint };
});
@ -217,7 +333,7 @@ export async function setupSearch(
return existingProvider;
}
const detected = SEARCH_PROVIDER_OPTIONS.find(
(e) => hasExistingKey(config, e.value) || hasKeyInEnv(e),
(e) => hasExistingKey(nextConfig, e.value) || hasKeyInEnv(e),
);
if (detected) {
return detected.value;
@ -240,25 +356,25 @@ export async function setupSearch(
});
if (choice === "__skip__") {
return config;
return nextConfig;
}
const entry = SEARCH_PROVIDER_OPTIONS.find((e) => e.value === choice)!;
const existingKey = resolveExistingKey(config, choice);
const keyConfigured = hasExistingKey(config, choice);
const existingKey = resolveExistingKey(nextConfig, choice);
const keyConfigured = hasExistingKey(nextConfig, choice);
const envAvailable = hasKeyInEnv(entry);
if (opts?.quickstartDefaults && (keyConfigured || envAvailable)) {
const result = existingKey
? applySearchKey(config, choice, existingKey)
: applyProviderOnly(config, choice);
return preserveDisabledState(config, result);
? applySearchKey(nextConfig, choice, existingKey)
: applyProviderOnly(nextConfig, choice);
return preserveDisabledState(nextConfig, result);
}
const useSecretRefMode = opts?.secretInputMode === "ref"; // pragma: allowlist secret
if (useSecretRefMode) {
if (keyConfigured) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
}
const ref = buildSearchEnvRef(choice);
await prompter.note(
@ -270,7 +386,7 @@ export async function setupSearch(
].join("\n"),
"Web search",
);
return applySearchKey(config, choice, ref);
return applySearchKey(nextConfig, choice, ref);
}
const keyInput = await prompter.text({
@ -285,15 +401,15 @@ export async function setupSearch(
const key = keyInput?.trim() ?? "";
if (key) {
const secretInput = resolveSearchSecretInput(choice, key, opts?.secretInputMode);
return applySearchKey(config, choice, secretInput);
return applySearchKey(nextConfig, choice, secretInput);
}
if (existingKey) {
return preserveDisabledState(config, applySearchKey(config, choice, existingKey));
return preserveDisabledState(nextConfig, applySearchKey(nextConfig, choice, existingKey));
}
if (keyConfigured || envAvailable) {
return preserveDisabledState(config, applyProviderOnly(config, choice));
return preserveDisabledState(nextConfig, applyProviderOnly(nextConfig, choice));
}
await prompter.note(
@ -306,13 +422,13 @@ export async function setupSearch(
);
return {
...config,
...nextConfig,
tools: {
...config.tools,
...nextConfig.tools,
web: {
...config.tools?.web,
...nextConfig.tools?.web,
search: {
...config.tools?.web?.search,
...nextConfig.tools?.web?.search,
provider: choice,
},
},

View File

@ -659,13 +659,30 @@ export const FIELD_HELP: Record<string, string> = {
"tools.message.crossContext.marker.suffix":
'Text suffix for cross-context markers (supports "{channel}").',
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
"tools.web.search.enabled":
"Enable managed web_search and optional Codex-native search for eligible models.",
"tools.web.search.provider":
'Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). Auto-detected from available API keys if omitted.',
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"tools.web.search.maxResults": "Number of results to return (1-10).",
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
"tools.web.search.openaiCodex.enabled":
"Enable native Codex web search for Codex-capable models.",
"tools.web.search.openaiCodex.mode":
'Native Codex web search mode: "cached" (default) or "live".',
"tools.web.search.openaiCodex.allowedDomains":
"Optional domain allowlist passed to the native Codex web_search tool.",
"tools.web.search.openaiCodex.contextSize":
'Native Codex search context size hint: "low", "medium", or "high".',
"tools.web.search.openaiCodex.userLocation.country":
"Approximate country sent to native Codex web search.",
"tools.web.search.openaiCodex.userLocation.region":
"Approximate region/state sent to native Codex web search.",
"tools.web.search.openaiCodex.userLocation.city":
"Approximate city sent to native Codex web search.",
"tools.web.search.openaiCodex.userLocation.timezone":
"Approximate timezone sent to native Codex web search.",
"tools.web.search.brave.mode":
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
"tools.web.search.gemini.apiKey":

View File

@ -218,6 +218,14 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.web.search.maxResults": "Web Search Max Results",
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
"tools.web.search.openaiCodex.enabled": "Enable Native Codex Web Search",
"tools.web.search.openaiCodex.mode": "Codex Web Search Mode",
"tools.web.search.openaiCodex.allowedDomains": "Codex Allowed Domains",
"tools.web.search.openaiCodex.contextSize": "Codex Search Context Size",
"tools.web.search.openaiCodex.userLocation.country": "Codex User Country",
"tools.web.search.openaiCodex.userLocation.region": "Codex User Region",
"tools.web.search.openaiCodex.userLocation.city": "Codex User City",
"tools.web.search.openaiCodex.userLocation.timezone": "Codex User Timezone",
"tools.web.search.brave.mode": "Brave Search Mode",
"tools.web.search.gemini.apiKey": "Gemini Search API Key", // pragma: allowlist secret
"tools.web.search.gemini.model": "Gemini Search Model",

View File

@ -455,7 +455,7 @@ export type ToolsConfig = {
byProvider?: Record<string, ToolPolicyConfig>;
web?: {
search?: {
/** Enable web search tool (default: true when API key is present). */
/** Enable managed web_search and optional Codex-native web search. */
enabled?: boolean;
/** Search provider ("brave", "gemini", "grok", "kimi", or "perplexity"). */
provider?: "brave" | "gemini" | "grok" | "kimi" | "perplexity";
@ -467,6 +467,24 @@ export type ToolsConfig = {
timeoutSeconds?: number;
/** Cache TTL in minutes for search results. */
cacheTtlMinutes?: number;
/** Optional native Codex web search for Codex-capable models. */
openaiCodex?: {
/** Enable native Codex web search for eligible models. */
enabled?: boolean;
/** Use cached or live external web access. Default: "cached". */
mode?: "cached" | "live";
/** Optional allowlist of domains passed to the native Codex tool. */
allowedDomains?: string[];
/** Optional Codex native search context size hint. */
contextSize?: "low" | "medium" | "high";
/** Optional approximate user location passed to the native Codex tool. */
userLocation?: {
country?: string;
region?: string;
city?: string;
timezone?: string;
};
};
/** Brave-specific configuration (used when provider="brave"). */
brave?: {
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { validateConfigObjectRaw } from "./validation.js";
describe("web search Codex native config validation", () => {
it("accepts tools.web.search.openaiCodex", () => {
const result = validateConfigObjectRaw({
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
allowedDomains: ["example.com"],
contextSize: "medium",
userLocation: {
country: "US",
city: "New York",
timezone: "America/New_York",
},
},
},
},
},
});
expect(result.ok).toBe(true);
});
it("rejects invalid openaiCodex.mode", () => {
const result = validateConfigObjectRaw({
tools: {
web: {
search: {
openaiCodex: {
enabled: true,
mode: "realtime",
},
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = result.issues.find(
(entry) => entry.path === "tools.web.search.openaiCodex.mode",
);
expect(issue?.allowedValues).toEqual(["cached", "live"]);
}
});
it("rejects invalid openaiCodex.contextSize", () => {
const result = validateConfigObjectRaw({
tools: {
web: {
search: {
openaiCodex: {
enabled: true,
contextSize: "huge",
},
},
},
},
});
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = result.issues.find(
(entry) => entry.path === "tools.web.search.openaiCodex.contextSize",
);
expect(issue?.allowedValues).toEqual(["low", "medium", "high"]);
}
});
});

View File

@ -259,6 +259,37 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
}
}).optional();
const TrimmedOptionalConfigStringSchema = z.preprocess((value) => {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}, z.string().optional());
const CodexAllowedDomainsSchema = z
.array(z.string())
.transform((values) => {
const deduped = [
...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)),
];
return deduped.length > 0 ? deduped : undefined;
})
.optional();
const CodexUserLocationSchema = z
.object({
country: TrimmedOptionalConfigStringSchema,
region: TrimmedOptionalConfigStringSchema,
city: TrimmedOptionalConfigStringSchema,
timezone: TrimmedOptionalConfigStringSchema,
})
.strict()
.transform((value) => {
return value.country || value.region || value.city || value.timezone ? value : undefined;
})
.optional();
export const ToolsWebSearchSchema = z
.object({
enabled: z.boolean().optional(),
@ -275,6 +306,16 @@ export const ToolsWebSearchSchema = z
maxResults: z.number().int().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
cacheTtlMinutes: z.number().nonnegative().optional(),
openaiCodex: z
.object({
enabled: z.boolean().optional(),
mode: z.union([z.literal("cached"), z.literal("live")]).optional(),
allowedDomains: CodexAllowedDomainsSchema,
contextSize: z.union([z.literal("low"), z.literal("medium"), z.literal("high")]).optional(),
userLocation: CodexUserLocationSchema,
})
.strict()
.optional(),
perplexity: z
.object({
apiKey: SecretInputSchema.optional().register(sensitive),

View File

@ -307,4 +307,54 @@ describe("finalizeOnboardingWizard", () => {
expect(progressUpdate).toHaveBeenCalledWith("Restarting Gateway service…");
expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled.");
});
it("shows a Codex native search summary when configured", async () => {
const note = vi.fn(async () => {});
const prompter = buildWizardPrompter({
note,
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
await finalizeOnboardingWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
enabled: true,
openaiCodex: {
enabled: true,
mode: "cached",
},
},
},
},
},
workspaceDir: "/tmp",
settings: {
port: 18789,
bind: "loopback",
authMode: "token",
gatewayToken: undefined,
tailscaleMode: "off",
tailscaleResetOnExit: false,
},
prompter,
runtime: createRuntime(),
});
expect(note).toHaveBeenCalledWith(
expect.stringContaining("Codex native search: cached for Codex-capable models"),
"Codex native search",
);
});
});

View File

@ -481,6 +481,8 @@ export async function finalizeOnboardingWizard(
);
}
const { describeCodexNativeWebSearch } = await import("../agents/codex-native-web-search.js");
const codexNativeSummary = describeCodexNativeWebSearch(nextConfig);
const webSearchProvider = nextConfig.tools?.web?.search?.provider;
const webSearchEnabled = nextConfig.tools?.web?.search?.enabled;
if (webSearchProvider) {
@ -549,6 +551,15 @@ export async function finalizeOnboardingWizard(
].join("\n"),
"Web search",
);
} else if (codexNativeSummary) {
await prompter.note(
[
"Managed web search provider was skipped.",
codexNativeSummary,
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Web search",
);
} else {
await prompter.note(
[
@ -562,6 +573,17 @@ export async function finalizeOnboardingWizard(
}
}
if (codexNativeSummary) {
await prompter.note(
[
codexNativeSummary,
"Used only for Codex-capable models.",
"Docs: https://docs.openclaw.ai/tools/web",
].join("\n"),
"Codex native search",
);
}
await prompter.note(
'What now: https://openclaw.ai/showcase ("What People Are Building").',
"What now",