fix(auth): persist codex oauth refresh tokens

This commit is contained in:
Peter Steinberger 2026-04-01 14:24:41 +01:00
parent af5f4f6716
commit 00218ac8a4
No known key found for this signature in database
3 changed files with 59 additions and 4 deletions

View File

@ -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

View File

@ -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<OAuthCredential | undefined> => 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<AuthProfileStore> {
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(

View File

@ -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,
};
}