mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 00:38:11 +00:00
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 (commits85f36e8d2band4624e34c06). Comments noting local-fork bookkeeping stripped per repo policy. Co-authored-by: Will <totalsolutionspm@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
156
src/agents/auth-profiles/store.sidecar-runtime-defaults.test.ts
Normal file
156
src/agents/auth-profiles/store.sidecar-runtime-defaults.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user