fix(doctor): warn when stale Codex overrides shadow OAuth (#40143)

* fix(doctor): warn on stale codex provider overrides

* test(doctor): cover stored codex oauth warning path

* fix: narrow codex override doctor warning (#40143) (thanks @bde1)

* test: sync doctor e2e mocks after health-flow move (#40143) (thanks @bde1)

---------

Co-authored-by: bde1 <bde1@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
B
2026-04-07 19:07:33 -07:00
committed by GitHub
parent 7fc3197ecb
commit 5050017543
7 changed files with 347 additions and 2 deletions

View File

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

View File

@@ -66,6 +66,7 @@ cat ~/.openclaw/openclaw.json
- Talk config migration from legacy flat `talk.*` fields into `talk.provider` + `talk.providers.<provider>`.
- 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:

View File

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

View File

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

View File

@@ -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),
}));

View File

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

View File

@@ -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<void>
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");