Skip to content

Commit 3ab58dc

Browse files
committed
fix(auth): emit one-shot doctor-pointer warning for Keychain-only legacy Codex OAuth profiles
1 parent 8ae9977 commit 3ab58dc

7 files changed

Lines changed: 183 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.
4848
- Live tests: fail Gateway live model sweeps when selected coverage is lost to timeouts or stale high-signal filters instead of reporting false missing-profile coverage, and pin Docker OpenAI gateway coverage to the current `gpt-5.5` lane.
4949
- Tests: fail Docker resource-ceiling checks when stats samples or configured limits are invalid instead of silently reporting zero peaks.
50+
- Auth/Codex: emit a one-shot actionable `log.warn` from the embedded legacy Codex OAuth sidecar loader when the only available seed lives in the macOS Keychain, naming `openclaw doctor --fix` and macOS Keychain instead of letting the credential silently fall through to a downstream `No API key found for provider "openai-codex"`. Thanks @romneyda.
5051
- Agents: fail closed when provider-less session models match multiple provider-prefixed runtime policies so CLI runtime routing no longer depends on config order. (#85970) Thanks @potterdigital.
5152

5253
## 2026.5.24

docs/gateway/doctor.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ That stages grounded durable candidates into the short-term dreaming store while
413413
- short cooldowns (rate limits/timeouts/auth failures)
414414
- longer disables (billing/credit failures)
415415

416-
Legacy Codex OAuth profiles whose tokens live in macOS Keychain (older onboarding before the file-based sidecar layout) are not picked up by the embedded runtime path — that path runs with `allowKeychainPrompt: false` and cannot trigger a Keychain prompt. Run `openclaw doctor --fix` once to migrate Keychain-backed legacy tokens inline into `auth-profiles.json`; after that, embedded turns (Telegram, cron, sub-agent dispatch) resolve them like any other inline OAuth profile.
416+
Legacy Codex OAuth profiles whose tokens live in macOS Keychain (older onboarding before the file-based sidecar layout) are not picked up by the embedded runtime path — that path runs with `allowKeychainPrompt: false` and cannot trigger a Keychain prompt. Affected users will see a one-shot `log.warn` from the legacy sidecar loader naming `openclaw doctor --fix` and macOS Keychain (instead of the credential silently falling through to a downstream `No API key found for provider "openai-codex"`). Run `openclaw doctor --fix` once from an interactive terminal to migrate Keychain-backed legacy tokens inline into `auth-profiles.json`; after that, embedded turns (Telegram, cron, sub-agent dispatch) resolve them like any other inline OAuth profile.
417417

418418
</Accordion>
419419
<Accordion title="6. Hooks model validation">
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
3+
import { loggingState } from "../../logging/state.js";
4+
import {
5+
createOpenClawTestState,
6+
type OpenClawTestState,
7+
} from "../../test-utils/openclaw-test-state.js";
8+
import {
9+
legacyOAuthSidecarInternalTestUtils,
10+
legacyOAuthSidecarTestUtils,
11+
loadLegacyOAuthSidecarMaterial,
12+
} from "./legacy-oauth-sidecar.js";
13+
14+
const states: OpenClawTestState[] = [];
15+
16+
function setPlatform(value: NodeJS.Platform): () => void {
17+
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
18+
Object.defineProperty(process, "platform", { value, configurable: true });
19+
return () => {
20+
if (descriptor) {
21+
Object.defineProperty(process, "platform", descriptor);
22+
}
23+
};
24+
}
25+
26+
async function writeLegacySidecarThatNeedsKeychain(): Promise<{
27+
state: OpenClawTestState;
28+
ref: { source: "openclaw-credentials"; provider: "openai-codex"; id: string };
29+
profileId: string;
30+
}> {
31+
const state = await createOpenClawTestState({
32+
layout: "state-only",
33+
prefix: "openclaw-legacy-oauth-keychain-warn-",
34+
env: {
35+
OPENCLAW_AGENT_DIR: undefined,
36+
OPENCLAW_AUTH_PROFILE_SECRET_KEY: undefined,
37+
},
38+
});
39+
states.push(state);
40+
const profileId = "openai-codex:default";
41+
const ref = {
42+
source: "openclaw-credentials" as const,
43+
provider: "openai-codex" as const,
44+
id: "0123456789abcdef0123456789abcdef",
45+
};
46+
await state.writeJson(`credentials/auth-profiles/${ref.id}.json`, {
47+
version: 1,
48+
profileId,
49+
provider: "openai-codex",
50+
encrypted: legacyOAuthSidecarTestUtils.encryptLegacyOAuthMaterial({
51+
ref,
52+
profileId,
53+
provider: "openai-codex",
54+
seed: "only-in-keychain",
55+
material: { access: "a", refresh: "b", idToken: "c" },
56+
}),
57+
});
58+
return { state, ref, profileId };
59+
}
60+
61+
afterEach(async () => {
62+
for (const state of states.splice(0)) {
63+
await state.cleanup();
64+
}
65+
legacyOAuthSidecarInternalTestUtils.resetKeychainOnlyMigrationHint();
66+
});
67+
68+
describe("loadLegacyOAuthSidecarMaterial keychain-only headless warning", () => {
69+
let restorePlatform: () => void;
70+
let warnSpy: ReturnType<typeof vi.fn>;
71+
72+
beforeEach(() => {
73+
restorePlatform = setPlatform("darwin");
74+
setLoggerOverride({ level: "warn", consoleLevel: "warn" });
75+
warnSpy = vi.fn();
76+
loggingState.rawConsole = {
77+
log: vi.fn(),
78+
info: vi.fn(),
79+
warn: warnSpy as unknown as typeof console.warn,
80+
error: vi.fn(),
81+
};
82+
});
83+
84+
afterEach(() => {
85+
restorePlatform();
86+
loggingState.rawConsole = null;
87+
setLoggerOverride(null);
88+
resetLogger();
89+
});
90+
91+
function envWithoutVitestSignals(state: OpenClawTestState): NodeJS.ProcessEnv {
92+
const env: NodeJS.ProcessEnv = { ...state.env };
93+
delete env.VITEST;
94+
delete env.VITEST_WORKER_ID;
95+
return env;
96+
}
97+
98+
it("emits a single doctor-pointer warning when only Keychain can decrypt and prompts are disabled", async () => {
99+
const { state, ref, profileId } = await writeLegacySidecarThatNeedsKeychain();
100+
const env = envWithoutVitestSignals(state);
101+
102+
const firstAttempt = loadLegacyOAuthSidecarMaterial({
103+
ref,
104+
profileId,
105+
provider: "openai-codex",
106+
allowKeychainPrompt: false,
107+
env,
108+
});
109+
expect(firstAttempt).toBeNull();
110+
expect(warnSpy).toHaveBeenCalledTimes(1);
111+
const [firstMessage] = warnSpy.mock.calls[0] as [unknown];
112+
expect(String(firstMessage)).toContain("openclaw doctor --fix");
113+
expect(String(firstMessage)).toContain("macOS Keychain");
114+
115+
const secondAttempt = loadLegacyOAuthSidecarMaterial({
116+
ref,
117+
profileId,
118+
provider: "openai-codex",
119+
allowKeychainPrompt: false,
120+
env,
121+
});
122+
expect(secondAttempt).toBeNull();
123+
expect(warnSpy).toHaveBeenCalledTimes(1);
124+
});
125+
126+
it("does not emit the doctor-pointer warning on non-darwin platforms", async () => {
127+
restorePlatform();
128+
restorePlatform = setPlatform("linux");
129+
const { state, ref, profileId } = await writeLegacySidecarThatNeedsKeychain();
130+
const env = envWithoutVitestSignals(state);
131+
132+
const attempt = loadLegacyOAuthSidecarMaterial({
133+
ref,
134+
profileId,
135+
provider: "openai-codex",
136+
allowKeychainPrompt: false,
137+
env,
138+
});
139+
expect(attempt).toBeNull();
140+
expect(warnSpy).not.toHaveBeenCalled();
141+
});
142+
});

src/agents/auth-profiles/legacy-oauth-sidecar.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from "node:os";
55
import path from "node:path";
66
import { resolveOAuthDir, resolveStateDir } from "../../config/paths.js";
77
import { loadJsonFile } from "../../infra/json-file.js";
8+
import { log } from "./constants.js";
89

910
const LEGACY_OAUTH_REF_SOURCE = "openclaw-credentials";
1011
const LEGACY_OAUTH_REF_PROVIDER = "openai-codex";
@@ -333,9 +334,38 @@ function decryptLegacyOAuthSecretMaterial(params: {
333334
if (keychainSeed && !seeds.includes(keychainSeed)) {
334335
return decryptLegacyOAuthSecretMaterialWithSeed(params, keychainSeed);
335336
}
337+
if (
338+
process.platform === "darwin" &&
339+
params.allowKeychainPrompt === false &&
340+
params.env.VITEST !== "true" &&
341+
params.env.VITEST_WORKER_ID === undefined
342+
) {
343+
emitKeychainOnlyMigrationHintOnce(params.profileId);
344+
}
336345
return null;
337346
}
338347

348+
let keychainOnlyMigrationHintEmitted = false;
349+
350+
function emitKeychainOnlyMigrationHintOnce(profileId: string): void {
351+
if (keychainOnlyMigrationHintEmitted) {
352+
return;
353+
}
354+
keychainOnlyMigrationHintEmitted = true;
355+
log.warn(
356+
"Legacy Codex OAuth credentials are stored only in macOS Keychain on this host. " +
357+
"Headless paths cannot prompt for Keychain access; run `openclaw doctor --fix` " +
358+
"from an interactive terminal to migrate them back to inline auth-profiles.json credentials.",
359+
{ profileId },
360+
);
361+
}
362+
363+
export const legacyOAuthSidecarInternalTestUtils = {
364+
resetKeychainOnlyMigrationHint(): void {
365+
keychainOnlyMigrationHintEmitted = false;
366+
},
367+
};
368+
339369
export function loadLegacyOAuthSidecarMaterial(params: {
340370
ref: LegacyOAuthRef;
341371
profileId: string;

src/commands/doctor-auth-oauth-sidecar.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ describe("maybeRepairLegacyOAuthSidecarProfiles", () => {
139139
expect(result.detected).toEqual([authPath]);
140140
expect(result.warnings).toStrictEqual([]);
141141
expect(result.changes).toStrictEqual([
142-
`Migrated 1 sidecar-backed Codex OAuth profile in ${authPath} to inline credentials (backup: ${authPath}.oauth-ref.123.bak).`,
142+
`Migrated 1 legacy Codex OAuth profile in ${authPath} to inline credentials (backup: ${authPath}.oauth-ref.123.bak).`,
143143
]);
144144
expect(fs.existsSync(sidecarPath)).toBe(false);
145145
expect(JSON.parse(fs.readFileSync(`${authPath}.oauth-ref.123.bak`, "utf8"))).toEqual(auth);
@@ -390,7 +390,7 @@ describe("maybeRepairLegacyOAuthSidecarProfiles", () => {
390390
expect(result.detected).toEqual([authPath]);
391391
expect(result.warnings).toStrictEqual([]);
392392
expect(result.changes).toStrictEqual([
393-
`Migrated 1 sidecar-backed Codex OAuth profile in ${authPath} to inline credentials (backup: ${authPath}.oauth-ref.789.bak).`,
393+
`Migrated 1 legacy Codex OAuth profile in ${authPath} to inline credentials (backup: ${authPath}.oauth-ref.789.bak).`,
394394
]);
395395
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({
396396
version: 1,
@@ -462,8 +462,8 @@ describe("maybeRepairLegacyOAuthSidecarProfiles", () => {
462462
expect(result.detected).toEqual([mainAuthPath, workerAuthPath]);
463463
expect(result.warnings).toStrictEqual([]);
464464
expect(result.changes).toEqual([
465-
`Migrated 1 sidecar-backed Codex OAuth profile in ${mainAuthPath} to inline credentials (backup: ${mainAuthPath}.oauth-ref.456.bak).`,
466-
`Migrated 1 sidecar-backed Codex OAuth profile in ${workerAuthPath} to inline credentials (backup: ${workerAuthPath}.oauth-ref.456.bak).`,
465+
`Migrated 1 legacy Codex OAuth profile in ${mainAuthPath} to inline credentials (backup: ${mainAuthPath}.oauth-ref.456.bak).`,
466+
`Migrated 1 legacy Codex OAuth profile in ${workerAuthPath} to inline credentials (backup: ${workerAuthPath}.oauth-ref.456.bak).`,
467467
]);
468468
for (const authPath of [mainAuthPath, workerAuthPath]) {
469469
expect(JSON.parse(fs.readFileSync(authPath, "utf8"))).toEqual({

src/commands/doctor-auth-oauth-sidecar.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export async function maybeRepairLegacyOAuthSidecarProfiles(params: {
222222
[
223223
...stores.map(
224224
(entry) =>
225-
`- ${shortenHomePath(entry.authPath)} has legacy sidecar-backed Codex OAuth profiles.`,
225+
`- ${shortenHomePath(entry.authPath)} has legacy Codex OAuth profiles to migrate.`,
226226
),
227227
...(unreferencedSidecars.length > 0
228228
? [
@@ -237,7 +237,7 @@ export async function maybeRepairLegacyOAuthSidecarProfiles(params: {
237237
}
238238

239239
const shouldRepair = await params.prompter.confirmAutoFix({
240-
message: "Migrate legacy sidecar-backed Codex OAuth credentials now?",
240+
message: "Migrate legacy Codex OAuth credentials now?",
241241
initialValue: true,
242242
});
243243
if (!shouldRepair) {
@@ -283,7 +283,7 @@ export async function maybeRepairLegacyOAuthSidecarProfiles(params: {
283283
migratedSidecarsByRefId.set(refId, sidecarPath);
284284
}
285285
result.changes.push(
286-
`Migrated ${migratedCount} sidecar-backed Codex OAuth profile${migratedCount === 1 ? "" : "s"} in ${shortenHomePath(store.authPath)} to inline credentials (backup: ${shortenHomePath(backupPath)}).`,
286+
`Migrated ${migratedCount} legacy Codex OAuth profile${migratedCount === 1 ? "" : "s"} in ${shortenHomePath(store.authPath)} to inline credentials (backup: ${shortenHomePath(backupPath)}).`,
287287
);
288288
} catch (err) {
289289
for (const refId of storeMigratedSidecarsByRefId.keys()) {

src/commands/doctor/repair-sequencing.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ describe("doctor repair sequencing", () => {
359359
events.push("sidecar-oauth");
360360
return {
361361
detected: ["auth-profiles.json"],
362-
changes: ["Migrated 1 sidecar-backed Codex OAuth profile."],
362+
changes: ["Migrated 1 legacy Codex OAuth profile."],
363363
warnings: ["Sidecar warning"],
364364
};
365365
});
@@ -389,7 +389,7 @@ describe("doctor repair sequencing", () => {
389389
env: process.env,
390390
});
391391
expect(result.changeNotes).toEqual([
392-
"Migrated 1 sidecar-backed Codex OAuth profile.",
392+
"Migrated 1 legacy Codex OAuth profile.",
393393
"Removed stale OAuth auth profile shadow openai-codex.",
394394
]);
395395
expect(result.warningNotes).toEqual(["Sidecar warning"]);

0 commit comments

Comments
 (0)