fix: merge concurrent oauth refresh writes

This commit is contained in:
Peter Steinberger
2026-05-16 17:32:54 +01:00
parent 66556cd0a1
commit 72c0b75c69
2 changed files with 122 additions and 5 deletions

View File

@@ -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<void>((resolve) => {
resolveBothStarted = resolve;
});
let releaseRefreshes!: () => void;
const refreshGate = new Promise<void>((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",
});
});
});

View File

@@ -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<void> {
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) {