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,