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 85f36e8d2b and
4624e34c06). Comments noting local-fork bookkeeping stripped per repo policy.

Co-authored-by: Will <totalsolutionspm@gmail.com>
This commit is contained in:
Dallin Romney
2026-05-21 13:07:49 -07:00
committed by GitHub
parent 016c34ff1d
commit 4399eee6e0
4 changed files with 169 additions and 6 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
## 2026.5.20

View File

@@ -413,6 +413,8 @@ That stages grounded durable candidates into the short-term dreaming store while
- short cooldowns (rate limits/timeouts/auth failures)
- longer disables (billing/credit failures)
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.
</Accordion>
<Accordion title="6. Hooks model validation">
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.

View File

@@ -0,0 +1,156 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resolveOAuthDir } from "../../config/paths.js";
import { AUTH_STORE_VERSION } from "./constants.js";
import { legacyOAuthSidecarTestUtils } from "./legacy-oauth-sidecar.js";
import { resolveAuthStorePath } from "./paths.js";
import {
clearRuntimeAuthProfileStoreSnapshots,
ensureAuthProfileStoreWithoutExternalProfiles,
loadAuthProfileStoreForSecretsRuntime,
loadAuthProfileStoreWithoutExternalProfiles,
} from "./store.js";
const PROFILE_ID = "openai-codex:default";
const SEED = "legacy-seed";
const SIDECAR_REF = {
source: "openclaw-credentials" as const,
provider: "openai-codex" as const,
id: "0123456789abcdef0123456789abcdef",
};
const envBackup: Record<string, string | undefined> = {};
const envKeys = ["OPENCLAW_STATE_DIR", "OPENCLAW_OAUTH_DIR", "OPENCLAW_AUTH_PROFILE_SECRET_KEY"];
const tempDirs: string[] = [];
beforeEach(() => {
for (const key of envKeys) {
envBackup[key] = process.env[key];
}
clearRuntimeAuthProfileStoreSnapshots();
});
afterEach(() => {
for (const key of envKeys) {
if (envBackup[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = envBackup[key];
}
}
clearRuntimeAuthProfileStoreSnapshots();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function setUpSidecarFixture(): { agentDir: string } {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sidecar-runtime-defaults-"));
tempDirs.push(stateDir);
process.env.OPENCLAW_STATE_DIR = stateDir;
delete process.env.OPENCLAW_OAUTH_DIR;
process.env.OPENCLAW_AUTH_PROFILE_SECRET_KEY = SEED;
const agentDir = path.join(stateDir, "agents", "main", "agent");
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(
resolveAuthStorePath(agentDir),
`${JSON.stringify(
{
version: AUTH_STORE_VERSION,
profiles: {
[PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
expires: 123456,
accountId: "acct-legacy",
oauthRef: SIDECAR_REF,
},
},
},
null,
2,
)}\n`,
);
const sidecarPath = path.join(resolveOAuthDir(), "auth-profiles", `${SIDECAR_REF.id}.json`);
fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
fs.writeFileSync(
sidecarPath,
`${JSON.stringify(
{
version: 1,
profileId: PROFILE_ID,
provider: "openai-codex",
encrypted: legacyOAuthSidecarTestUtils.encryptLegacyOAuthMaterial({
ref: SIDECAR_REF,
profileId: PROFILE_ID,
provider: "openai-codex",
seed: SEED,
material: {
access: "legacy-access-token",
refresh: "legacy-refresh-token",
idToken: "legacy-id-token",
},
}),
},
null,
2,
)}\n`,
);
return { agentDir };
}
describe("secrets-runtime store loaders rehydrate legacy oauthRef sidecars by default", () => {
it("loadAuthProfileStoreForSecretsRuntime hydrates inline tokens", () => {
const { agentDir } = setUpSidecarFixture();
const credential = loadAuthProfileStoreForSecretsRuntime(agentDir).profiles[PROFILE_ID];
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "legacy-access-token",
refresh: "legacy-refresh-token",
idToken: "legacy-id-token",
});
expect(credential).not.toHaveProperty("oauthRef");
});
it("loadAuthProfileStoreWithoutExternalProfiles hydrates inline tokens", () => {
const { agentDir } = setUpSidecarFixture();
const credential = loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[PROFILE_ID];
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "legacy-access-token",
refresh: "legacy-refresh-token",
idToken: "legacy-id-token",
});
expect(credential).not.toHaveProperty("oauthRef");
});
it("ensureAuthProfileStoreWithoutExternalProfiles hydrates inline tokens", () => {
const { agentDir } = setUpSidecarFixture();
const credential = ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles[PROFILE_ID];
expect(credential).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "legacy-access-token",
refresh: "legacy-refresh-token",
idToken: "legacy-id-token",
});
expect(credential).not.toHaveProperty("oauthRef");
});
it("explicit resolveLegacyOAuthSidecars: false still opts out of sidecar hydration", () => {
const { agentDir } = setUpSidecarFixture();
const credential = loadAuthProfileStoreWithoutExternalProfiles(agentDir, {
resolveLegacyOAuthSidecars: false,
}).profiles[PROFILE_ID];
expect(credential).not.toHaveProperty("access");
expect(credential).not.toHaveProperty("refresh");
expect(credential).not.toHaveProperty("idToken");
});
});

View File

@@ -590,7 +590,7 @@ export function loadAuthProfileStoreForSecretsRuntime(agentDir?: string): AuthPr
return loadAuthProfileStoreForRuntime(agentDir, {
readOnly: true,
allowKeychainPrompt: false,
resolveLegacyOAuthSidecars: false,
resolveLegacyOAuthSidecars: true,
});
}
@@ -604,7 +604,7 @@ export function loadAuthProfileStoreWithoutExternalProfiles(
const options: LoadAuthProfileStoreOptions = {
readOnly: true,
allowKeychainPrompt: loadOptions?.allowKeychainPrompt ?? false,
resolveLegacyOAuthSidecars: loadOptions?.resolveLegacyOAuthSidecars ?? false,
resolveLegacyOAuthSidecars: loadOptions?.resolveLegacyOAuthSidecars ?? true,
};
const store = loadAuthProfileStoreForAgent(agentDir, options);
const authPath = resolveAuthStorePath(agentDir);
@@ -639,20 +639,24 @@ export function ensureAuthProfileStore(
export function ensureAuthProfileStoreWithoutExternalProfiles(
agentDir?: string,
options?: { allowKeychainPrompt?: boolean },
options?: { allowKeychainPrompt?: boolean; resolveLegacyOAuthSidecars?: boolean },
): AuthProfileStore {
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, options);
const effectiveOptions: LoadAuthProfileStoreOptions = {
...options,
resolveLegacyOAuthSidecars: options?.resolveLegacyOAuthSidecars ?? true,
};
const runtimeStore = resolveRuntimeAuthProfileStore(agentDir, effectiveOptions);
if (runtimeStore) {
return runtimeStore;
}
const store = loadAuthProfileStoreForAgent(agentDir, options);
const store = loadAuthProfileStoreForAgent(agentDir, effectiveOptions);
const authPath = resolveAuthStorePath(agentDir);
const mainAuthPath = resolveAuthStorePath();
if (!agentDir || authPath === mainAuthPath) {
return store;
}
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions);
return mergeAuthProfileStores(mainStore, store);
}