Compare commits

...

8 Commits

Author SHA1 Message Date
Christof Salis
e1e51171b0 Merge remote-tracking branch 'origin/main' into cs/codex-native-web-search-spec
# Conflicts:
#	src/agents/pi-embedded-runner/extra-params.ts
#	src/agents/pi-embedded-runner/openai-stream-wrappers.ts
2026-03-17 06:25:55 +01:00
Christof Salis
f7fc816546 CLI: suppress Codex native search summary when web search is off 2026-03-17 06:18:17 +01:00
Christof Salis
9261d75139 Lint: fix merged plugin auth typing 2026-03-17 00:02:56 +01:00
Christof Salis
5373dc9dec Merge remote-tracking branch 'origin/cs/codex-native-web-search-spec' into cs/codex-native-web-search-spec 2026-03-16 23:59:34 +01:00
Christof Salis
bf703972c4 Merge remote-tracking branch 'origin/main' into cs/codex-native-web-search-spec
# Conflicts:
#	src/agents/pi-embedded-runner/extra-params.ts
#	src/commands/auth-choice.apply.openai.ts
#	src/commands/models/auth.ts
#	src/commands/onboard-search.test.ts
2026-03-16 23:56:17 +01:00
Andrew Demczuk
e28da68cf4
Merge branch 'main' into cs/codex-native-web-search-spec 2026-03-15 14:23:48 +01:00
Christof Salis
160fda3f29 Codex: use model-level API for native search relevance 2026-03-15 11:40:51 +01:00
Christof Salis
21270f900b Codex: add native web search for embedded Pi runs 2026-03-15 11:20:05 +01:00
37 changed files with 1701 additions and 114 deletions

View File

@ -58,6 +58,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

@ -1,8 +1,8 @@
import type { DiscordAccountConfig } from "../../../src/config/types.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "../../../src/config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,

View File

@ -1,5 +1,3 @@
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import {
applyAccountNameToChannelSection,
@ -19,6 +17,8 @@ import {
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";

View File

@ -1,8 +1,8 @@
import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js";
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { cloneFirstTemplateModel } from "../../src/plugins/provider-model-helpers.js";
const GEMINI_3_1_PRO_PREFIX = "gemini-3.1-pro";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";

View File

@ -1,6 +1,6 @@
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
import { listIMessageAccountIds, resolveIMessageAccount } from "./accounts.js";
import {
createIMessageCliPathTextInput,

View File

@ -1,4 +1,3 @@
import { formatCliCommand } from "../../../src/cli/command-format.js";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
@ -18,6 +17,7 @@ import type {
ChannelSetupWizard,
ChannelSetupWizardTextInput,
} from "openclaw/plugin-sdk/setup";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import {
listSignalAccountIds,

View File

@ -1,7 +1,7 @@
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
import { installSignalCli } from "../../../src/commands/signal-install.js";
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { detectBinary } from "../../../src/commands/onboard-helpers.js";
import { installSignalCli } from "../../../src/commands/signal-install.js";
import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js";
import {
createSignalCliPathTextInput,

View File

@ -19,9 +19,9 @@ import {
type ChannelSetupWizard,
type ChannelSetupWizardAllowFromEntry,
} from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js";
import { createAllowlistSetupWizardProxy } from "../../../src/channels/plugins/setup-wizard-proxy.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { inspectSlackAccount } from "./account-inspect.js";
import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js";
import {

View File

@ -1,4 +1,3 @@
import { formatCliCommand } from "../../../src/cli/command-format.js";
import {
applyAccountNameToChannelSection,
DEFAULT_ACCOUNT_ID,
@ -11,6 +10,7 @@ import {
type WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
import { fetchTelegramChatId } from "./api-fetch.js";

View File

@ -1,6 +1,4 @@
import path from "node:path";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { DmPolicy } from "../../../src/config/types.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
@ -12,6 +10,8 @@ import {
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { DmPolicy } from "../../../src/config/types.js";
import { formatDocsLink } from "../../../src/terminal/links.js";
import { listWhatsAppAccountIds, resolveWhatsAppAuthDir } from "./accounts.js";
import { loginWeb } from "./login.js";

View File

@ -0,0 +1,270 @@
import { describe, expect, it } from "vitest";
import {
buildCodexNativeWebSearchTool,
describeCodexNativeWebSearch,
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("omits the summary when global web search is disabled", () => {
expect(
describeCodexNativeWebSearch({
tools: {
web: {
search: {
enabled: false,
openaiCodex: {
enabled: true,
mode: "live",
},
},
},
},
}),
).toBeUndefined();
});
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);
});
});
describe("isCodexNativeWebSearchRelevant", () => {
it("treats a default model with model-level openai-codex-responses api as relevant", async () => {
const { isCodexNativeWebSearchRelevant } = await import("./codex-native-web-search.js");
expect(
isCodexNativeWebSearchRelevant({
config: {
agents: {
defaults: {
model: {
primary: "gateway/gpt-5.4",
},
},
},
models: {
providers: {
gateway: {
api: "openai-responses",
baseUrl: "https://gateway.example/v1",
models: [
{
id: "gpt-5.4",
name: "gpt-5.4",
api: "openai-codex-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 16_384,
},
],
},
},
},
},
}),
).toBe(true);
});
});

View File

@ -0,0 +1,307 @@
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];
const configuredModelApi = configuredProvider?.models?.find(
(candidate) => candidate.id === defaultModel.model,
)?.api;
return isCodexNativeSearchEligibleModel({
modelProvider: defaultModel.provider,
modelApi: configuredModelApi ?? configuredProvider?.api,
});
}
export function describeCodexNativeWebSearch(
config: OpenClawConfig | undefined,
): string | undefined {
if (config?.tools?.web?.search?.enabled === false) {
return undefined;
}
const nativeConfig = resolveCodexNativeWebSearchConfig(config);
if (!nativeConfig.enabled) {
return undefined;
}
return `Codex native search: ${nativeConfig.mode} for Codex-capable models`;
}

View File

@ -14,7 +14,7 @@
*/
import { EventEmitter } from "node:events";
import WebSocket from "ws";
import WebSocket, { type ClientOptions as WebSocketClientOptions } from "ws";
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
// ─────────────────────────────────────────────────────────────────────────────
@ -268,7 +268,7 @@ export interface OpenAIWebSocketManagerOptions {
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
backoffDelaysMs?: readonly number[];
/** Custom socket factory for tests. */
socketFactory?: (url: string, options: ConstructorParameters<typeof WebSocket>[1]) => WebSocket;
socketFactory?: (url: string, options: WebSocketClientOptions) => WebSocket;
}
type InternalEvents = {
@ -308,10 +308,7 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
private readonly wsUrl: string;
private readonly maxRetries: number;
private readonly backoffDelaysMs: readonly number[];
private readonly socketFactory: (
url: string,
options: ConstructorParameters<typeof WebSocket>[1],
) => WebSocket;
private readonly socketFactory: (url: string, options: WebSocketClientOptions) => WebSocket;
constructor(options: OpenAIWebSocketManagerOptions = {}) {
super();

View File

@ -1261,6 +1261,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

@ -581,6 +581,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

@ -26,6 +26,8 @@ import {
shouldApplySiliconFlowThinkingOffCompat,
} from "./moonshot-stream-wrappers.js";
import {
createCodexDefaultTransportWrapper,
createCodexNativeWebSearchWrapper,
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIFastModeWrapper,
@ -277,6 +279,7 @@ export function applyExtraParamsToAgent(
extraParamsOverride?: Record<string, unknown>,
thinkingLevel?: ThinkLevel,
agentId?: string,
agentDir?: string,
): void {
const resolvedExtraParams = resolveExtraParams({
cfg,
@ -308,6 +311,9 @@ export function applyExtraParamsToAgent(
if (provider === "openai") {
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
} else {
// Default Codex to WebSocket-first when nothing else specifies transport.
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
}
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
}
@ -403,6 +409,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 { resolveProviderAttributionHeaders } from "../provider-attribution.js";
import { log } from "./logger.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
@ -369,6 +374,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

@ -1523,6 +1523,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,
@ -1966,6 +1967,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;
/**
@ -567,8 +588,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

@ -14,6 +14,7 @@ import { isRemoteEnvironment } from "./oauth-env.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { openUrl } from "./onboard-helpers.js";
import type { OnboardOptions } from "./onboard-types.js";
import { resolveProviderPostAuthGuidance } from "./provider-auth-guidance.js";
import {
applyDefaultModel,
mergeConfigPatch,
@ -77,7 +78,11 @@ export async function runProviderPluginAuthMethod(params: {
secretInputMode?: OnboardOptions["secretInputMode"];
allowSecretRefPrompt?: boolean;
opts?: Partial<OnboardOptions>;
}): Promise<{ config: ApplyAuthChoiceParams["config"]; defaultModel?: string }> {
}): Promise<{
config: ApplyAuthChoiceParams["config"];
defaultModel?: string;
profileCount: number;
}> {
const agentId = params.agentId ?? resolveDefaultAgentId(params.config);
const defaultAgentId = resolveDefaultAgentId(params.config);
const agentDir =
@ -138,6 +143,7 @@ export async function runProviderPluginAuthMethod(params: {
return {
config: nextConfig,
defaultModel: result.defaultModel,
profileCount: result.profiles.length,
};
}
@ -177,6 +183,11 @@ export async function applyAuthChoiceLoadedPluginProvider(
});
let nextConfig = applied.config;
if (applied.profileCount > 0 || applied.defaultModel) {
for (const guidance of resolveProviderPostAuthGuidance(resolved.provider.id)) {
await params.prompter.note(guidance.message, guidance.title);
}
}
let agentModelOverride: string | undefined;
if (applied.defaultModel) {
if (params.setDefaultModel) {
@ -263,6 +274,11 @@ export async function applyAuthChoicePluginProvider(
opts: params.opts as ProviderAuthOptionBag | undefined,
});
nextConfig = applied.config;
if (applied.profileCount > 0 || applied.defaultModel) {
for (const guidance of resolveProviderPostAuthGuidance(provider.id)) {
await params.prompter.note(guidance.message, guidance.title);
}
}
let agentModelOverride: string | undefined;
if (applied.defaultModel) {

View File

@ -282,7 +282,8 @@ describe("applyAuthChoice", () => {
},
] as never);
const prompter = createPrompter({});
const note = vi.fn(async () => {});
const prompter = createPrompter({ note });
const runtime = createExitThrowingRuntime();
const result = await applyAuthChoice({
@ -305,6 +306,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 defaultProvider = SEARCH_PROVIDER_OPTIONS[0]?.value;
if (!defaultProvider) {
@ -200,7 +202,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",
@ -221,62 +223,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

@ -216,6 +216,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

@ -36,6 +36,7 @@ import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { isRemoteEnvironment } from "../oauth-env.js";
import { createVpsAwareOAuthHandlers } from "../oauth-flow.js";
import { openUrl } from "../onboard-helpers.js";
import { resolveProviderPostAuthGuidance } from "../provider-auth-guidance.js";
import {
applyDefaultModel,
mergeConfigPatch,
@ -297,6 +298,14 @@ async function runProviderAuthMethod(params: {
prompter: params.prompter,
setDefault: params.setDefault,
});
if (result.profiles.length > 0 || result.defaultModel) {
for (const guidance of resolveProviderPostAuthGuidance(params.provider.id)) {
if (guidance.runtimeMessage) {
params.runtime.log(guidance.runtimeMessage);
}
}
}
}
export async function modelsAuthSetupTokenCommand(

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 () => {
@ -164,7 +175,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 {
@ -184,11 +195,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);
});
@ -203,11 +216,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);
@ -225,7 +242,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;
@ -344,6 +361,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 6 providers in SEARCH_PROVIDER_OPTIONS", () => {
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6);
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,
@ -162,7 +166,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;
};
@ -176,16 +206,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 };
});
@ -195,7 +311,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;
@ -218,25 +334,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(
@ -248,7 +364,7 @@ export async function setupSearch(
].join("\n"),
"Web search",
);
return applySearchKey(config, choice, ref);
return applySearchKey(nextConfig, choice, ref);
}
const keyInput = await prompter.text({
@ -263,15 +379,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(
@ -284,13 +400,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

@ -36,6 +36,31 @@ export function resolveProviderAuthLoginCommand(params: {
return formatCliCommand(`openclaw models auth login --provider ${provider.id}`);
}
export type ProviderPostAuthGuidance = {
title: string;
message: string;
runtimeMessage?: string;
};
export function resolveProviderPostAuthGuidance(provider: string): ProviderPostAuthGuidance[] {
if (normalizeProviderId(provider) !== "openai-codex") {
return [];
}
return [
{
title: "Web search",
message: [
"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"),
runtimeMessage:
"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 function buildProviderAuthRecoveryHint(params: {
provider: string;
config?: OpenClawConfig;

View File

@ -665,13 +665,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", "firecrawl", "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.firecrawl.apiKey":

View File

@ -220,6 +220,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.firecrawl.apiKey": "Firecrawl Search API Key", // pragma: allowlist secret
"tools.web.search.firecrawl.baseUrl": "Firecrawl Search Base URL",

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", "firecrawl", "gemini", "grok", "kimi", or "perplexity"). */
provider?: "brave" | "firecrawl" | "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

@ -260,6 +260,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(),
@ -277,6 +308,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

@ -13,7 +13,10 @@ export type {
VideoDescriptionResult,
} from "../media-understanding/types.js";
export { describeImageWithModel, describeImagesWithModel } from "../media-understanding/providers/image.js";
export {
describeImageWithModel,
describeImagesWithModel,
} from "../media-understanding/providers/image.js";
export { transcribeOpenAiCompatibleAudio } from "../media-understanding/providers/openai-compatible-audio.js";
export {
assertOkOrThrowHttpError,

View File

@ -307,4 +307,104 @@ describe("finalizeSetupWizard", () => {
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 finalizeSetupWizard({
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",
);
});
it("does not show a Codex native search summary when web search is globally disabled", async () => {
const note = vi.fn(async () => {});
const prompter = buildWizardPrompter({
note,
select: vi.fn(async () => "later") as never,
confirm: vi.fn(async () => false),
});
await finalizeSetupWizard({
flow: "advanced",
opts: {
acceptRisk: true,
authChoice: "skip",
installDaemon: false,
skipHealth: true,
skipUi: true,
},
baseConfig: {},
nextConfig: {
tools: {
web: {
search: {
enabled: false,
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).not.toHaveBeenCalledWith(
expect.stringContaining("Codex native search:"),
"Codex native search",
);
});
});

View File

@ -481,6 +481,8 @@ export async function finalizeSetupWizard(
);
}
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 finalizeSetupWizard(
].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 finalizeSetupWizard(
}
}
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",