diff --git a/CHANGELOG.md b/CHANGELOG.md index 068e38eb607..01b8ff06db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 27bcd2e6fb6..a1265b583c4 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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. + 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. diff --git a/src/agents/auth-profiles/store.sidecar-runtime-defaults.test.ts b/src/agents/auth-profiles/store.sidecar-runtime-defaults.test.ts new file mode 100644 index 00000000000..0bf4240cd68 --- /dev/null +++ b/src/agents/auth-profiles/store.sidecar-runtime-defaults.test.ts @@ -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 = {}; +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"); + }); +}); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 7f74bb3ed0d..621e8d8b89c 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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); }