Skip to content

Commit 4399eee

Browse files
fix(auth): load legacy Codex OAuth sidecars in embedded secrets-runtime loaders (#85074)
The auto-migration introduced in #83312 only fires when a credential is loaded via a path that reads its sidecar tokens. The OAuth refresh manager's internal loader does (so direct CLI inference works and self-heals on first refresh). The embedded runner's secrets-runtime loaders did not: - loadAuthProfileStoreForSecretsRuntime - loadAuthProfileStoreWithoutExternalProfiles - ensureAuthProfileStoreWithoutExternalProfiles All three opted out of sidecar resolution. So for an upgraded user with a legacy oauthRef-backed openai-codex profile, the credential loaded with no access/refresh material, evaluateStoredCredentialEligibility marked it ineligible, resolveAuthProfileOrder filtered it out, and resolveApiKeyForProvider threw "No API key found for provider 'openai-codex'" before the OAuth manager (and its migration path) was ever consulted. CLI worked, Telegram/cron/embedded turns broke — only doctor-or-bust would fix it. Flip the three embedded loaders to default resolveLegacyOAuthSidecars to true (matching loadStoredOAuthRefreshStore). The existing #83312 refresh-and-rewrite then fires on the first embedded turn for these users and persists tokens inline, removing the legacy sidecar from disk on the next doctor pass. Cherry-picked and squashed from PR #84752 (commits 85f36e8 and 4624e34). Comments noting local-fork bookkeeping stripped per repo policy. Co-authored-by: Will <totalsolutionspm@gmail.com>
1 parent 016c34f commit 4399eee

4 files changed

Lines changed: 169 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
5151
- CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.
5252
- Codex/Lossless: keep context-engine history on the canonical run session when Telegram DMs use per-peer runtime policy keys. Fixes #84936. (#84954) Thanks @neeravmakwana.
5353
- Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.
54+
- Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
5455
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
5556

5657
## 2026.5.20

docs/gateway/doctor.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,8 @@ 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.
417+
416418
</Accordion>
417419
<Accordion title="6. Hooks model validation">
418420
If `hooks.gmail.model` is set, doctor validates the model reference against the catalog and allowlist and warns when it won't resolve or is disallowed.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { resolveOAuthDir } from "../../config/paths.js";
6+
import { AUTH_STORE_VERSION } from "./constants.js";
7+
import { legacyOAuthSidecarTestUtils } from "./legacy-oauth-sidecar.js";
8+
import { resolveAuthStorePath } from "./paths.js";
9+
import {
10+
clearRuntimeAuthProfileStoreSnapshots,
11+
ensureAuthProfileStoreWithoutExternalProfiles,
12+
loadAuthProfileStoreForSecretsRuntime,
13+
loadAuthProfileStoreWithoutExternalProfiles,
14+
} from "./store.js";
15+
16+
const PROFILE_ID = "openai-codex:default";
17+
const SEED = "legacy-seed";
18+
const SIDECAR_REF = {
19+
source: "openclaw-credentials" as const,
20+
provider: "openai-codex" as const,
21+
id: "0123456789abcdef0123456789abcdef",
22+
};
23+
24+
const envBackup: Record<string, string | undefined> = {};
25+
const envKeys = ["OPENCLAW_STATE_DIR", "OPENCLAW_OAUTH_DIR", "OPENCLAW_AUTH_PROFILE_SECRET_KEY"];
26+
const tempDirs: string[] = [];
27+
28+
beforeEach(() => {
29+
for (const key of envKeys) {
30+
envBackup[key] = process.env[key];
31+
}
32+
clearRuntimeAuthProfileStoreSnapshots();
33+
});
34+
35+
afterEach(() => {
36+
for (const key of envKeys) {
37+
if (envBackup[key] === undefined) {
38+
delete process.env[key];
39+
} else {
40+
process.env[key] = envBackup[key];
41+
}
42+
}
43+
clearRuntimeAuthProfileStoreSnapshots();
44+
for (const dir of tempDirs.splice(0)) {
45+
fs.rmSync(dir, { recursive: true, force: true });
46+
}
47+
});
48+
49+
function setUpSidecarFixture(): { agentDir: string } {
50+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sidecar-runtime-defaults-"));
51+
tempDirs.push(stateDir);
52+
process.env.OPENCLAW_STATE_DIR = stateDir;
53+
delete process.env.OPENCLAW_OAUTH_DIR;
54+
process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = SEED;
55+
56+
const agentDir = path.join(stateDir, "agents", "main", "agent");
57+
fs.mkdirSync(agentDir, { recursive: true });
58+
fs.writeFileSync(
59+
resolveAuthStorePath(agentDir),
60+
`${JSON.stringify(
61+
{
62+
version: AUTH_STORE_VERSION,
63+
profiles: {
64+
[PROFILE_ID]: {
65+
type: "oauth",
66+
provider: "openai-codex",
67+
expires: 123456,
68+
accountId: "acct-legacy",
69+
oauthRef: SIDECAR_REF,
70+
},
71+
},
72+
},
73+
null,
74+
2,
75+
)}\n`,
76+
);
77+
78+
const sidecarPath = path.join(resolveOAuthDir(), "auth-profiles", `${SIDECAR_REF.id}.json`);
79+
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
80+
fs.writeFileSync(
81+
sidecarPath,
82+
`${JSON.stringify(
83+
{
84+
version: 1,
85+
profileId: PROFILE_ID,
86+
provider: "openai-codex",
87+
encrypted: legacyOAuthSidecarTestUtils.encryptLegacyOAuthMaterial({
88+
ref: SIDECAR_REF,
89+
profileId: PROFILE_ID,
90+
provider: "openai-codex",
91+
seed: SEED,
92+
material: {
93+
access: "legacy-access-token",
94+
refresh: "legacy-refresh-token",
95+
idToken: "legacy-id-token",
96+
},
97+
}),
98+
},
99+
null,
100+
2,
101+
)}\n`,
102+
);
103+
104+
return { agentDir };
105+
}
106+
107+
describe("secrets-runtime store loaders rehydrate legacy oauthRef sidecars by default", () => {
108+
it("loadAuthProfileStoreForSecretsRuntime hydrates inline tokens", () => {
109+
const { agentDir } = setUpSidecarFixture();
110+
const credential = loadAuthProfileStoreForSecretsRuntime(agentDir).profiles[PROFILE_ID];
111+
expect(credential).toMatchObject({
112+
type: "oauth",
113+
provider: "openai-codex",
114+
access: "legacy-access-token",
115+
refresh: "legacy-refresh-token",
116+
idToken: "legacy-id-token",
117+
});
118+
expect(credential).not.toHaveProperty("oauthRef");
119+
});
120+
121+
it("loadAuthProfileStoreWithoutExternalProfiles hydrates inline tokens", () => {
122+
const { agentDir } = setUpSidecarFixture();
123+
const credential = loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[PROFILE_ID];
124+
expect(credential).toMatchObject({
125+
type: "oauth",
126+
provider: "openai-codex",
127+
access: "legacy-access-token",
128+
refresh: "legacy-refresh-token",
129+
idToken: "legacy-id-token",
130+
});
131+
expect(credential).not.toHaveProperty("oauthRef");
132+
});
133+
134+
it("ensureAuthProfileStoreWithoutExternalProfiles hydrates inline tokens", () => {
135+
const { agentDir } = setUpSidecarFixture();
136+
const credential = ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[PROFILE_ID];
137+
expect(credential).toMatchObject({
138+
type: "oauth",
139+
provider: "openai-codex",
140+
access: "legacy-access-token",
141+
refresh: "legacy-refresh-token",
142+
idToken: "legacy-id-token",
143+
});
144+
expect(credential).not.toHaveProperty("oauthRef");
145+
});
146+
147+
it("explicit resolveLegacyOAuthSidecars: false still opts out of sidecar hydration", () => {
148+
const { agentDir } = setUpSidecarFixture();
149+
const credential = loadAuthProfileStoreWithoutExternalProfiles(agentDir, {
150+
resolveLegacyOAuthSidecars: false,
151+
}).profiles[PROFILE_ID];
152+
expect(credential).not.toHaveProperty("access");
153+
expect(credential).not.toHaveProperty("refresh");
154+
expect(credential).not.toHaveProperty("idToken");
155+
});
156+
});

src/agents/auth-profiles/store.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthPr
590590
return loadAuthProfileStoreForRuntime(agentDir, {
591591
readOnly: true,
592592
allowKeychainPrompt: false,
593-
resolveLegacyOAuthSidecars: false,
593+
resolveLegacyOAuthSidecars: true,
594594
});
595595
}
596596

@@ -604,7 +604,7 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
604604
const options: LoadAuthProfileStoreOptions = {
605605
readOnly: true,
606606
allowKeychainPrompt: loadOptions?.allowKeychainPrompt ?? false,
607-
resolveLegacyOAuthSidecars: loadOptions?.resolveLegacyOAuthSidecars ?? false,
607+
resolveLegacyOAuthSidecars: loadOptions?.resolveLegacyOAuthSidecars ?? true,
608608
};
609609
const store = loadAuthProfileStoreForAgent(agentDir, options);
610610
const authPath = resolveAuthStorePath(agentDir);
@@ -639,20 +639,24 @@ export function ensureAuthProfileStore(
639639

640640
export function ensureAuthProfileStoreWithoutExternalProfiles(
641641
agentDir?: string,
642-
options?: { allowKeychainPrompt?: boolean },
642+
options?: { allowKeychainPrompt?: boolean; resolveLegacyOAuthSidecars?: boolean },
643643
): AuthProfileStore {
644-
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, options);
644+
const effectiveOptions: LoadAuthProfileStoreOptions = {
645+
...options,
646+
resolveLegacyOAuthSidecars: options?.resolveLegacyOAuthSidecars ?? true,
647+
};
648+
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, effectiveOptions);
645649
if (runtimeStore) {
646650
return runtimeStore;
647651
}
648-
const store = loadAuthProfileStoreForAgent(agentDir, options);
652+
const store = loadAuthProfileStoreForAgent(agentDir, effectiveOptions);
649653
const authPath = resolveAuthStorePath(agentDir);
650654
const mainAuthPath = resolveAuthStorePath();
651655
if (!agentDir || authPath === mainAuthPath) {
652656
return store;
653657
}
654658

655-
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
659+
const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions);
656660
return mergeAuthProfileStores(mainStore, store);
657661
}
658662

0 commit comments

Comments
 (0)