diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f61c04241..879a1a13282 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1774,6 +1774,9 @@ Docs: https://docs.openclaw.ai - macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH. - macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv. - macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek. +- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. +- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. +- Doctor/Codex OAuth: warn only for legacy `models.providers.openai-codex` transport overrides that can shadow the built-in Codex OAuth path, while leaving supported custom proxies and header-only overrides alone. (#40143) Thanks @bde1. - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. - ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 1cef59476fd..17091d5283f 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -66,6 +66,7 @@ cat ~/.openclaw/openclaw.json - Talk config migration from legacy flat `talk.*` fields into `talk.provider` + `talk.providers.`. - Browser migration checks for legacy Chrome extension configs and Chrome MCP readiness. - OpenCode provider override warnings (`models.providers.opencode` / `models.providers.opencode-go`). +- Codex OAuth shadowing warnings (`models.providers.openai-codex`). - OAuth TLS prerequisites check for OpenAI Codex OAuth profiles. - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy plugin manifest contract key migration (`speechProviders`, `realtimeTranscriptionProviders`, `realtimeVoiceProviders`, `mediaUnderstandingProviders`, `imageGenerationProviders`, `videoGenerationProviders`, `webFetchProviders`, `webSearchProviders` → `contracts`). @@ -212,6 +213,16 @@ doctor prints platform-specific fix guidance. On macOS with a Homebrew Node, the fix is usually `brew postinstall ca-certificates`. With `--deep`, the probe runs even if the gateway is healthy. +### 2c) Codex OAuth provider overrides + +If you previously added legacy OpenAI transport settings under +`models.providers.openai-codex`, they can shadow the built-in Codex OAuth +provider path that newer releases use automatically. Doctor warns when it sees +those old transport settings alongside Codex OAuth so you can remove or rewrite +the stale transport override and get the built-in routing/fallback behavior +back. Custom proxies and header-only overrides are still supported and do not +trigger this warning. + ### 3) Legacy state migrations (disk layout) Doctor can migrate older on-disk layouts into the current structure: diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index b5aa16441c4..cfd0162a768 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -15,9 +15,15 @@ import type { OpenClawConfig } from "../config/config.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolvePluginProviders } from "../plugins/providers.runtime.js"; import { note } from "../terminal/note.js"; +import { isRecord } from "../utils.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; import { buildProviderAuthRecoveryHint } from "./provider-auth-guidance.js"; +const CODEX_PROVIDER_ID = "openai-codex"; +const CODEX_OAUTH_WARNING_TITLE = "Codex OAuth"; +const OPENAI_BASE_URL = "https://api.openai.com/v1"; +const LEGACY_CODEX_APIS = new Set(["openai-responses", "openai-completions"]); + export async function maybeRepairLegacyOAuthProfileIds( cfg: OpenClawConfig, prompter: DoctorPrompter, @@ -55,6 +61,89 @@ export async function maybeRepairLegacyOAuthProfileIds( return nextCfg; } +function hasConfiguredCodexOAuthProfile(cfg: OpenClawConfig): boolean { + return Object.values(cfg.auth?.profiles ?? {}).some( + (profile) => profile.provider === CODEX_PROVIDER_ID && profile.mode === "oauth", + ); +} + +function hasStoredCodexOAuthProfile(): boolean { + const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); + return Object.values(store.profiles).some( + (profile) => profile.provider === CODEX_PROVIDER_ID && profile.type === "oauth", + ); +} + +function normalizeCodexOverrideBaseUrl(baseUrl: unknown): string | undefined { + if (typeof baseUrl !== "string") { + return undefined; + } + return baseUrl.trim().replace(/\/+$/, ""); +} + +function isLegacyCodexTransportShape(value: unknown, inheritedBaseUrl?: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const api = typeof value.api === "string" ? value.api : undefined; + if (!api || !LEGACY_CODEX_APIS.has(api)) { + return false; + } + const baseUrl = normalizeCodexOverrideBaseUrl(value.baseUrl ?? inheritedBaseUrl); + return !baseUrl || baseUrl === OPENAI_BASE_URL; +} + +function hasLegacyCodexTransportOverride(providerOverride: unknown): boolean { + if (!isRecord(providerOverride)) { + return false; + } + if (isLegacyCodexTransportShape(providerOverride)) { + return true; + } + const models = providerOverride.models; + if (!Array.isArray(models)) { + return false; + } + return models.some((model) => isLegacyCodexTransportShape(model, providerOverride.baseUrl)); +} + +function buildCodexProviderOverrideWarning(providerOverride: unknown): string { + const lines = [ + `- models.providers.${CODEX_PROVIDER_ID} contains a legacy transport override while Codex OAuth is configured.`, + "- Older OpenAI transport settings can shadow the built-in Codex OAuth provider path.", + ]; + if (isRecord(providerOverride)) { + const record = providerOverride; + if (typeof record.api === "string") { + lines.push(`- models.providers.${CODEX_PROVIDER_ID}.api=${record.api}`); + } + if (typeof record.baseUrl === "string") { + lines.push(`- models.providers.${CODEX_PROVIDER_ID}.baseUrl=${record.baseUrl}`); + } + } + lines.push( + `- Remove or rewrite the legacy transport override to restore the built-in Codex OAuth provider path after recent fixes.`, + ); + lines.push( + "- Custom proxies and header-only overrides can stay; this warning only targets old OpenAI transport settings.", + ); + return lines.join("\n"); +} + +export function noteLegacyCodexProviderOverride(cfg: OpenClawConfig): void { + const providerOverride = cfg.models?.providers?.[CODEX_PROVIDER_ID]; + if (!providerOverride) { + return; + } + if (!hasLegacyCodexTransportOverride(providerOverride)) { + return; + } + if (!hasConfiguredCodexOAuthProfile(cfg) && !hasStoredCodexOAuthProfile()) { + return; + } + note(buildCodexProviderOverrideWarning(providerOverride), CODEX_OAUTH_WARNING_TITLE); +} + type AuthIssue = { profileId: string; provider: string; diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 378d690529d..a0261a7d2f1 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -76,6 +76,11 @@ export const listPluginDoctorLegacyConfigRules = vi.fn(() => []) as unknown as M export const runDoctorHealthContributions = vi.fn( defaultRunDoctorHealthContributions, ) as unknown as MockFn; +export const maybeRepairMemoryRecallHealth = vi + .fn() + .mockResolvedValue(undefined) as unknown as MockFn; +export const noteMemorySearchHealth = vi.fn().mockResolvedValue(undefined) as unknown as MockFn; +export const noteMemoryRecallHealth = vi.fn().mockResolvedValue(undefined) as unknown as MockFn; export const migrateLegacyConfig = vi.fn((raw: unknown) => ({ config: raw as Record, changes: ["Moved routing.allowFrom → channels.whatsapp.allowFrom."], @@ -339,6 +344,12 @@ vi.mock("../flows/doctor-health-contributions.js", () => ({ runDoctorHealthContributions, })); +vi.mock("./doctor-memory-search.js", () => ({ + maybeRepairMemoryRecallHealth, + noteMemorySearchHealth, + noteMemoryRecallHealth, +})); + vi.mock("../plugins/doctor-contract-registry.js", () => ({ listPluginDoctorLegacyConfigRules, })); @@ -494,6 +505,9 @@ beforeEach(() => { runGatewayUpdate.mockReset().mockResolvedValue(createGatewayUpdateResult()); listPluginDoctorLegacyConfigRules.mockReset().mockReturnValue([]); runDoctorHealthContributions.mockReset().mockImplementation(defaultRunDoctorHealthContributions); + maybeRepairMemoryRecallHealth.mockReset().mockResolvedValue(undefined); + noteMemorySearchHealth.mockReset().mockResolvedValue(undefined); + noteMemoryRecallHealth.mockReset().mockResolvedValue(undefined); legacyReadConfigFileSnapshot.mockReset().mockResolvedValue(createLegacyConfigSnapshot()); createConfigIO.mockReset().mockImplementation(() => ({ readConfigFileSnapshot: legacyReadConfigFileSnapshot, diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 6c517310ca4..10e9cf7d5ad 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -26,6 +26,8 @@ vi.mock("./doctor-gateway-health.js", () => ({ })); vi.mock("./doctor-memory-search.js", () => ({ + maybeRepairMemoryRecallHealth: vi.fn().mockResolvedValue(undefined), + noteMemoryRecallHealth: vi.fn().mockResolvedValue(undefined), noteMemorySearchHealth: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 49826e7e3bc..4295dc739d4 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -2,7 +2,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { createDoctorRuntime, mockDoctorConfigSnapshot } from "./doctor.e2e-harness.js"; +import { + createDoctorRuntime, + ensureAuthProfileStore, + mockDoctorConfigSnapshot, +} from "./doctor.e2e-harness.js"; import { loadDoctorCommandForTest, terminalNoteMock } from "./doctor.note-test-helpers.js"; import "./doctor.fast-path-mocks.js"; @@ -11,7 +15,7 @@ let doctorCommand: typeof import("./doctor.js").doctorCommand; describe("doctor command", () => { beforeEach(async () => { doctorCommand = await loadDoctorCommandForTest({ - unmockModules: ["./doctor-state-integrity.js"], + unmockModules: ["../flows/doctor-health-contributions.js", "./doctor-state-integrity.js"], }); }); @@ -65,6 +69,226 @@ describe("doctor command", () => { expect(warned).toBe(true); }); + it("warns when a legacy openai-codex provider override shadows configured Codex OAuth", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }, + }, + auth: { + profiles: { + "openai-codex:user@example.com": { + provider: "openai-codex", + mode: "oauth", + email: "user@example.com", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some( + ([message, title]) => + title === "Codex OAuth" && String(message).includes("models.providers.openai-codex"), + ); + expect(warned).toBe(true); + }); + + it("warns when a legacy openai-codex provider override shadows stored Codex OAuth", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai-codex:user@example.com": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }, + }, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some( + ([message, title]) => + title === "Codex OAuth" && String(message).includes("models.providers.openai-codex"), + ); + expect(warned).toBe(true); + }); + + it("warns when an inline openai-codex model keeps the legacy OpenAI transport", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + models: [ + { + id: "gpt-5.4", + api: "openai-responses", + }, + ], + }, + }, + }, + auth: { + profiles: { + "openai-codex:user@example.com": { + provider: "openai-codex", + mode: "oauth", + email: "user@example.com", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some( + ([message, title]) => + title === "Codex OAuth" && String(message).includes("legacy transport override"), + ); + expect(warned).toBe(true); + }); + + it("does not warn for a custom openai-codex proxy override", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + api: "openai-responses", + baseUrl: "https://custom.example.com", + }, + }, + }, + auth: { + profiles: { + "openai-codex:user@example.com": { + provider: "openai-codex", + mode: "oauth", + email: "user@example.com", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some(([, title]) => title === "Codex OAuth"); + expect(warned).toBe(false); + }); + + it("does not warn for header-only openai-codex overrides", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + baseUrl: "https://custom.example.com", + headers: { "X-Custom-Auth": "token-123" }, + models: [{ id: "gpt-5.4" }], + }, + }, + }, + auth: { + profiles: { + "openai-codex:user@example.com": { + provider: "openai-codex", + mode: "oauth", + email: "user@example.com", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some(([, title]) => title === "Codex OAuth"); + expect(warned).toBe(false); + }); + it("does not warn about an openai-codex provider override without Codex OAuth", async () => { + mockDoctorConfigSnapshot({ + config: { + models: { + providers: { + "openai-codex": { + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + }, + }, + }, + }); + ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + + const warned = terminalNoteMock.mock.calls.some(([, title]) => title === "Codex OAuth"); + expect(warned).toBe(false); + }); + it("skips gateway auth warning when OPENCLAW_GATEWAY_TOKEN is set", async () => { mockDoctorConfigSnapshot({ config: { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 5f24f03de36..e2acfd5be58 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -12,6 +12,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { maybeRepairLegacyOAuthProfileIds, noteAuthProfileHealth, + noteLegacyCodexProviderOverride, } from "../commands/doctor-auth.js"; import { noteBootstrapFileSize } from "../commands/doctor-bootstrap-size.js"; import { noteChromeMcpBrowserReadiness } from "../commands/doctor-browser.js"; @@ -147,6 +148,7 @@ async function runAuthProfileHealth(ctx: DoctorHealthFlowContext): Promise prompter: ctx.prompter, allowKeychainPrompt: ctx.options.nonInteractive !== true && Boolean(process.stdin.isTTY), }); + noteLegacyCodexProviderOverride(ctx.cfg); ctx.gatewayDetails = buildGatewayConnectionDetails({ config: ctx.cfg }); if (ctx.gatewayDetails.remoteFallbackNote) { note(ctx.gatewayDetails.remoteFallbackNote, "Gateway");