From 5a202f6f909daf37beb951252a80a27f280e4c31 Mon Sep 17 00:00:00 2001 From: Gforce10-design Date: Sat, 25 Apr 2026 09:50:01 +0900 Subject: [PATCH] fix(auth): bootstrap codex cli credential without clobbering local (#71310) * fix(auth): bootstrap codex cli credential without clobbering local readCodexCliCredentialsCached was imported but never registered in EXTERNAL_CLI_SYNC_PROVIDERS, so overlayExternalAuthProfiles could not seed openai-codex:default on fresh agents and runtime surfaced "No API key found for provider openai-codex" even after a successful codex login. Register the provider with a new bootstrapOnly flag. Providers flagged bootstrapOnly are adopted only to fill an empty slot: the overlay skips them when a local OAuth credential already exists for the profile, and readExternalCliBootstrapCredential returns null so the refresh path never replaces the locally stored canonical refresh token with stale CLI state. Minimax keeps its existing replace-on-expiry behavior. * test(auth): cover codex cli bootstrap --------- Co-authored-by: sudol Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/concepts/oauth.md | 14 +++-- .../auth-profiles.external-cli-sync.test.ts | 53 +++++++++++++++++++ src/agents/auth-profiles/external-cli-sync.ts | 37 ++++++++++++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7eabb1d314..867007240f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Auth/Codex: bootstrap `openai-codex:default` from Codex CLI credentials on fresh installs without replacing a locally refreshed OpenClaw OAuth token later. Fixes #71305. Thanks @Gforce10-design. - Plugin SDK/tool-result transforms: bound middleware `details`, validate in-place result mutations, and mark fail-closed middleware fallbacks with canonical `error` status. Thanks @vincentkoc. - Discord/gateway: prevent startup from getting stuck at `awaiting gateway readiness` when Carbon gateway registration races with a lifecycle reconnect. Fixes #52372. (#68159) Thanks @IVY-AI-gif. - Discord/gateway: supervise Carbon's async gateway registration promise so fatal Discord metadata failures surface through startup instead of process-level unhandled rejections. (#62451) Thanks @safzanpirani. diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 17bb084ed52..9d098dcb95e 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -44,9 +44,10 @@ To reduce that, OpenClaw treats `auth-profiles.json` as a **token sink**: - the runtime reads credentials from **one place** - we can keep multiple profiles and route them deterministically -- when credentials are reused from an external CLI like Codex CLI, OpenClaw - mirrors them with provenance and re-reads that external source instead of - rotating the refresh token itself +- external CLI reuse is provider-specific: Codex CLI can bootstrap an empty + `openai-codex:default` profile, but once OpenClaw has a local OAuth profile, + the local refresh token is canonical; other integrations can remain + externally managed and re-read their CLI auth store ## Storage (where tokens live) @@ -128,8 +129,11 @@ At runtime: - if `expires` is in the future → use the stored access token - if expired → refresh (under a file lock) and overwrite the stored credentials -- exception: reused external CLI credentials stay externally managed; OpenClaw - re-reads the CLI auth store and never spends the copied refresh token itself +- exception: some external CLI credentials stay externally managed; OpenClaw + re-reads those CLI auth stores instead of spending copied refresh tokens. + Codex CLI bootstrap is intentionally narrower: it seeds an empty + `openai-codex:default` profile, then OpenClaw-owned refreshes keep the local + profile canonical. The refresh flow is automatic; you generally don't need to manage tokens manually. diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index a8620be1932..b7451589a9e 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -236,6 +236,59 @@ describe("external cli oauth resolution", () => { expect(credential).toBeNull(); }); + it("bootstraps the default codex profile from Codex CLI credentials when missing locally", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "codex-cli-access", + refresh: "codex-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + accountId: "acct-codex", + }), + ); + + const profiles = resolveExternalCliAuthProfiles(makeStore()); + + expect(profiles).toEqual([ + { + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: expect.objectContaining({ + provider: "openai-codex", + access: "codex-cli-access", + refresh: "codex-cli-refresh", + accountId: "acct-codex", + }), + }, + ]); + }); + + it("keeps any existing default codex oauth over Codex CLI bootstrap credentials", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "codex-cli-fresh-access", + refresh: "codex-cli-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + accountId: "acct-codex", + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "local-expired-access", + refresh: "local-canonical-refresh", + expires: Date.now() - 5_000, + accountId: "acct-codex", + }), + ), + ); + + expect(profiles).toEqual([]); + }); + it("returns null when the profile id/provider do not map to the same external source", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex" }), diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 63ee725ad76..de4e8b1122c 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,5 +1,12 @@ -import { readMiniMaxCliCredentialsCached } from "../cli-credentials.js"; -import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID } from "./constants.js"; +import { + readCodexCliCredentialsCached, + readMiniMaxCliCredentialsCached, +} from "../cli-credentials.js"; +import { + EXTERNAL_CLI_SYNC_TTL_MS, + MINIMAX_CLI_PROFILE_ID, + OPENAI_CODEX_DEFAULT_PROFILE_ID, +} from "./constants.js"; import { log } from "./constants.js"; import { areOAuthCredentialsEquivalent, @@ -29,6 +36,12 @@ type ExternalCliSyncProvider = { profileId: string; provider: string; readCredentials: () => OAuthCredential | null; + // bootstrapOnly providers adopt the external CLI credential only to + // seed an empty slot; once a local OAuth credential exists for the + // profile, the local refresh token is treated as canonical and the + // CLI state must not replace or shadow it. Codex requires this to + // avoid clobbering a locally refreshed token with stale CLI state. + bootstrapOnly?: boolean; }; function normalizeAuthIdentityToken(value: string | undefined): string | undefined { @@ -72,6 +85,12 @@ export function isSafeToUseExternalCliCredential( } const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ + { + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + provider: "openai-codex", + readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + bootstrapOnly: true, + }, { profileId: MINIMAX_CLI_PROFILE_ID, provider: "minimax-portal", @@ -103,6 +122,13 @@ export function readExternalCliBootstrapCredential(params: { if (!provider) { return null; } + // bootstrapOnly providers must not replace an existing local credential + // during runtime refresh. The oauth-manager only calls this hook when a + // local credential is already present, so returning null here keeps the + // locally stored refresh token canonical. + if (provider.bootstrapOnly) { + return null; + } return provider.readCredentials(); } @@ -132,6 +158,13 @@ export function resolveExternalCliAuthProfiles( }); continue; } + if (providerConfig.bootstrapOnly && existingOAuth) { + log.debug("kept local oauth over external cli bootstrap-only provider", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + }); + continue; + } if (existingOAuth && !isSafeToUseExternalCliCredential(existingOAuth, creds)) { log.warn("refused external cli oauth bootstrap: identity mismatch", { profileId: providerConfig.profileId,