mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user