From 00218ac8a467a2141c63fff8a01cf767863850c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Apr 2026 14:24:41 +0100 Subject: [PATCH] fix(auth): persist codex oauth refresh tokens --- CHANGELOG.md | 1 + ...auth.openai-codex-refresh-fallback.test.ts | 51 ++++++++++++++++++- src/agents/auth-profiles/oauth.ts | 11 +++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec3aa62acc..52b3833abf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - Auth profiles/store: coerce misplaced SecretRef objects out of plaintext `key` and `token` fields during store load so agents without ACP runtime stop crashing on `.trim()` after upgrade. (#58923) Thanks @openperf. - ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana - ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus. +- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082) ## 2026.3.31 diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index ff648983a4f..981f89b4638 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -9,7 +9,7 @@ import { ensureAuthProfileStore, saveAuthProfileStore, } from "./store.js"; -import type { AuthProfileStore } from "./types.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; const { getOAuthApiKeyMock } = vi.hoisted(() => ({ @@ -24,7 +24,7 @@ const { buildProviderAuthDoctorHintWithPluginMock, } = vi.hoisted(() => ({ refreshProviderOAuthCredentialWithPluginMock: vi.fn( - async (_params?: { context?: unknown }) => undefined, + async (_params?: { context?: unknown }): Promise => undefined, ), formatProviderAuthProfileApiKeyWithPluginMock: vi.fn(() => undefined), buildProviderAuthDoctorHintWithPluginMock: vi.fn(async () => undefined), @@ -61,6 +61,12 @@ async function loadFreshOAuthModuleForTest() { ({ resolveApiKeyForProfile } = await import("./oauth.js")); } +async function readPersistedStore(agentDir: string): Promise { + return JSON.parse( + await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"), + ) as AuthProfileStore; +} + function createExpiredOauthStore(params: { profileId: string; provider: string; @@ -142,6 +148,47 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); }); + it("persists plugin-refreshed openai-codex credentials before returning", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + createExpiredOauthStore({ + profileId, + provider: "openai-codex", + access: "stale-access-token", + }), + agentDir, + ); + refreshProviderOAuthCredentialWithPluginMock.mockResolvedValueOnce({ + type: "oauth", + provider: "openai-codex", + access: "rotated-access-token", + refresh: "rotated-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-rotated", + }); + + const result = await resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + }); + + expect(result).toEqual({ + apiKey: "rotated-access-token", + provider: "openai-codex", + email: undefined, + }); + + const persisted = await readPersistedStore(agentDir); + expect(persisted.profiles[profileId]).toMatchObject({ + type: "oauth", + provider: "openai-codex", + access: "rotated-access-token", + refresh: "rotated-refresh-token", + accountId: "acct-rotated", + }); + }); + it("keeps throwing for non-codex providers on the same refresh error", async () => { const profileId = "anthropic:default"; saveAuthProfileStore( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index a733d5ad91a..6c3ac2b8d53 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -185,9 +185,16 @@ async function refreshOAuthTokenWithLock(params: { context: cred, }); if (pluginRefreshed) { + const refreshedCredentials: OAuthCredential = { + ...cred, + ...pluginRefreshed, + type: "oauth", + }; + store.profiles[params.profileId] = refreshedCredentials; + saveAuthProfileStore(store, params.agentDir); return { - apiKey: await buildOAuthApiKey(cred.provider, pluginRefreshed), - newCredentials: pluginRefreshed, + apiKey: await buildOAuthApiKey(cred.provider, refreshedCredentials), + newCredentials: refreshedCredentials, }; }