diff --git a/src/agents/auth-profiles/oauth-manager.test.ts b/src/agents/auth-profiles/oauth-manager.test.ts index 8e0b215da71..dbc126dde1d 100644 --- a/src/agents/auth-profiles/oauth-manager.test.ts +++ b/src/agents/auth-profiles/oauth-manager.test.ts @@ -20,6 +20,7 @@ import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, ensureAuthProfileStoreWithoutExternalProfiles, + loadAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore, } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; @@ -660,4 +661,98 @@ describe("createOAuthManager", () => { expect(surfacedCauseMessage).not.toContain("external-attempt-id-token"); } }); + + it("merges concurrent refresh writes for different profiles in the same store", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-manager-refresh-merge-")); + tempDirs.push(tempRoot); + process.env.OPENCLAW_STATE_DIR = tempRoot; + const agentDir = path.join(tempRoot, "agents", "main", "agent"); + process.env.OPENCLAW_AGENT_DIR = agentDir; + process.env.PI_CODING_AGENT_DIR = agentDir; + await fs.mkdir(agentDir, { recursive: true }); + + const firstProfileId = "openai-codex:first"; + const secondProfileId = "openai-codex:second"; + const firstCredential = createCredential({ + access: "first-old-access", + refresh: "first-old-refresh", + expires: Date.now() - 60_000, + }); + const secondCredential = createCredential({ + access: "second-old-access", + refresh: "second-old-refresh", + expires: Date.now() - 60_000, + }); + saveAuthProfileStore( + { + version: 1, + profiles: { + [firstProfileId]: firstCredential, + [secondProfileId]: secondCredential, + }, + }, + agentDir, + { filterExternalAuthProfiles: false }, + ); + + let resolveBothStarted!: () => void; + const bothStarted = new Promise((resolve) => { + resolveBothStarted = resolve; + }); + let releaseRefreshes!: () => void; + const refreshGate = new Promise((resolve) => { + releaseRefreshes = resolve; + }); + const refreshInputs: string[] = []; + const manager = createOAuthManager({ + buildApiKey: async (_provider, credential) => credential.access, + refreshCredential: vi.fn(async (credential) => { + refreshInputs.push(credential.access); + if (refreshInputs.length === 2) { + resolveBothStarted(); + } + await refreshGate; + return { + access: `${credential.access}-rotated`, + refresh: `${credential.refresh}-rotated`, + expires: Date.now() + 60_000, + }; + }), + readBootstrapCredential: () => null, + isRefreshTokenReusedError: () => false, + }); + + const firstRefresh = manager.resolveOAuthAccess({ + store: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }), + profileId: firstProfileId, + credential: firstCredential, + agentDir, + forceRefresh: true, + }); + const secondRefresh = manager.resolveOAuthAccess({ + store: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }), + profileId: secondProfileId, + credential: secondCredential, + agentDir, + forceRefresh: true, + }); + + await bothStarted; + releaseRefreshes(); + await Promise.all([firstRefresh, secondRefresh]); + + const saved = loadAuthProfileStoreWithoutExternalProfiles(agentDir); + expect(saved.profiles[firstProfileId]).toMatchObject({ + access: "first-old-access-rotated", + refresh: "first-old-refresh-rotated", + }); + expect(saved.profiles[secondProfileId]).toMatchObject({ + access: "second-old-access-rotated", + refresh: "second-old-refresh-rotated", + }); + }); }); diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 64d04050876..cb48b93fd2b 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -30,7 +30,6 @@ import { import { ensureAuthProfileStoreWithoutExternalProfiles, loadAuthProfileStoreWithoutExternalProfiles, - saveAuthProfileStore, resolvePersistedAuthProfileOwnerAgentDir, updateAuthProfileStoreWithLock, } from "./store.js"; @@ -405,6 +404,23 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { } } + async function saveOAuthCredentialIntoStore(params: { + agentDir?: string; + profileId: string; + credential: OAuthCredential; + }): Promise { + const updated = await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir, + updater: (store) => { + store.profiles[params.profileId] = { ...params.credential }; + return true; + }, + }); + if (!updated) { + throw new Error("Failed to save refreshed OAuth credential."); + } + } + async function doRefreshOAuthTokenWithLock(params: { profileId: string; provider: string; @@ -508,8 +524,11 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { shouldReplaceStoredOAuthCredential(cred, externallyManaged) && !areOAuthCredentialsEquivalent(cred, externallyManaged) ) { - store.profiles[params.profileId] = { ...externallyManaged }; - saveAuthProfileStore(store, ownerAgentDir); + await saveOAuthCredentialIntoStore({ + agentDir: ownerAgentDir, + profileId: params.profileId, + credential: externallyManaged, + }); } credentialToRefresh = externallyManaged; if (!params.forceRefresh && hasUsableOAuthCredential(externallyManaged)) { @@ -545,8 +564,11 @@ export function createOAuthManager(adapter: OAuthManagerAdapter) { if (!refreshedCredentials) { return null; } - store.profiles[params.profileId] = refreshedCredentials; - saveAuthProfileStore(store, ownerAgentDir); + await saveOAuthCredentialIntoStore({ + agentDir: ownerAgentDir, + profileId: params.profileId, + credential: refreshedCredentials, + }); if (ownerAgentDir) { const mainStoreKey = resolveAuthProfileStoreKey(undefined); if (mainStoreKey !== ownerStoreKey) {