diff --git a/.changeset/fn-8205-auth-lock-retry.md b/.changeset/fn-8205-auth-lock-retry.md new file mode 100644 index 0000000000..96ad5c2bc2 --- /dev/null +++ b/.changeset/fn-8205-auth-lock-retry.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent transient credential-file lock contention from terminating provider runs. +category: fix +dev: Uses queued async auth writes with a shared proper-lockfile retry budget. diff --git a/packages/cli/src/commands/__tests__/onboard.test.ts b/packages/cli/src/commands/__tests__/onboard.test.ts index bd760265d3..86a5c50906 100644 --- a/packages/cli/src/commands/__tests__/onboard.test.ts +++ b/packages/cli/src/commands/__tests__/onboard.test.ts @@ -154,6 +154,23 @@ describe("onboard", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists")); }); + it("waits for API-key persistence before reporting onboarding success", async () => { + const providerAuth = makeProviderAuth(); + let persisted = false; + providerAuth.setApiKey.mockImplementation(async () => { + await Promise.resolve(); + persisted = true; + }); + mockProviderAuthFactory.mockReturnValue(providerAuth); + const logSpy = vi.spyOn(console, "log").mockImplementation((message: string) => { + if (message.includes("Stored API key")) expect(persisted).toBe(true); + }); + + await runOnboard({ input: inputFrom(["y", "y", "1", "test-key", "y", "y", "n", "y"]) }); + expect(providerAuth.setApiKey).toHaveBeenCalledWith("openrouter", "test-key"); + expect(logSpy).toHaveBeenCalledWith("✓ Stored API key for openrouter"); + }); + it("stores API key, runs init, persists global testMode and completion marker", async () => { const providerAuth = makeProviderAuth(); mockProviderAuthFactory.mockReturnValue(providerAuth); diff --git a/packages/cli/src/commands/__tests__/provider-auth.test.ts b/packages/cli/src/commands/__tests__/provider-auth.test.ts index d706cb825a..e854476a4e 100644 --- a/packages/cli/src/commands/__tests__/provider-auth.test.ts +++ b/packages/cli/src/commands/__tests__/provider-auth.test.ts @@ -26,7 +26,7 @@ function makeAuthStorage(credentials: Record { +describe("wrapAuthStorageWithApiKeyProviders", async () => { it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => { const fusionAuth = makeAuthStorage({ openrouter: { type: "api_key", key: "fusion-key" }, @@ -45,7 +45,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(wrapped.get("minimax")).toEqual({ type: "api_key", key: "legacy-minimax-key" }); }); - it("writes API keys only to Fusion auth storage", () => { + it("writes API keys only to Fusion auth storage", async () => { const fusionAuth = makeAuthStorage(); const legacyAuth = makeAuthStorage({ openrouter: { type: "api_key", key: "legacy-key" }, @@ -53,13 +53,13 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]); - wrapped.setApiKey("openrouter", "fusion-key"); + await wrapped.setApiKey("openrouter", "fusion-key"); expect(fusionAuth.set).toHaveBeenCalledWith("openrouter", { type: "api_key", key: "fusion-key" }); expect(legacyAuth.set).not.toHaveBeenCalled(); }); - it("reloads all read stores so status reflects both locations", () => { + it("reloads all read stores so status reflects both locations", async () => { const fusionAuth = makeAuthStorage(); const legacyAuth = makeAuthStorage(); const modelRegistry = { getAll: vi.fn(() => []) } as any; @@ -87,7 +87,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(merged.list()).toEqual(expect.arrayContaining(["openrouter", "minimax"])); }); - it("excludes pi-claude-cli models from API key providers", () => { + it("excludes pi-claude-cli models from API key providers", async () => { const fusionAuth = makeAuthStorage(); const modelRegistry = { getAll: vi.fn(() => [ @@ -103,7 +103,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(providerIds).not.toContain("pi-claude-cli"); }); - it("includes research-only API-key providers", () => { + it("includes research-only API-key providers", async () => { const fusionAuth = makeAuthStorage(); const modelRegistry = { getAll: vi.fn(() => []) } as any; @@ -114,7 +114,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(providerIds).toContain("tavily"); }); - it("always includes opencode-go when registry has no opencode models", () => { + it("always includes opencode-go when registry has no opencode models", async () => { const fusionAuth = makeAuthStorage(); const modelRegistry = { getAll: vi.fn(() => []) } as any; @@ -124,7 +124,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(providerIds).toContain("opencode-go"); }); - it("keeps explicit API-key aliases when OAuth provider ids collide", () => { + it("keeps explicit API-key aliases when OAuth provider ids collide", async () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "anthropic", name: "Anthropic OAuth" }, @@ -182,8 +182,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(await storage.getApiKey("anthropic")).toBeUndefined(); }); - describe("Anthropic provider classification", () => { - it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", () => { + describe("Anthropic provider classification", async () => { + it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", async () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "anthropic", name: "Anthropic" }, @@ -203,7 +203,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(apiKeyProviders).toContainEqual({ id: "anthropic-api-key", name: "Anthropic API Key" }); }); - it("keeps OpenAI API-key provider id unchanged when OAuth uses openai-codex", () => { + it("keeps OpenAI API-key provider id unchanged when OAuth uses openai-codex", async () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "openai-codex", name: "OpenAI Codex" }, @@ -219,7 +219,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(apiKeyProviders.some((p) => p.id === "openai-codex")).toBe(false); }); - it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", () => { + it("keeps only explicit built-ins when a model-registry-derived provider is also OAuth-backed", async () => { const fusionAuth = makeAuthStorage(); fusionAuth.getOAuthProviders = vi.fn(() => [ { id: "openai", name: "OpenAI OAuth" }, @@ -265,7 +265,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-existing"); }); - it("logs out Anthropic subscription alias without clearing the raw API key", () => { + it("logs out Anthropic subscription alias without clearing the raw API key", async () => { const fusionAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "sk-ant-api03-existing" }, "anthropic-subscription": { @@ -281,14 +281,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.logout("anthropic-subscription"); + await wrapped.logout("anthropic-subscription"); expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription"); expect(fusionAuth.logout).not.toHaveBeenCalledWith("anthropic"); expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-existing" }); }); - it("logs out legacy Anthropic OAuth stored under the raw anthropic id", () => { + it("logs out legacy Anthropic OAuth stored under the raw anthropic id", async () => { const fusionAuth = makeAuthStorage({ anthropic: { type: "oauth", @@ -305,7 +305,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); expect(wrapped.get("anthropic")?.type).toBe("oauth"); - wrapped.logout("anthropic"); + await wrapped.logout("anthropic"); wrapped.reload(); expect(fusionAuth.get).toHaveBeenCalledWith("anthropic"); @@ -324,7 +324,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-test-key"); + await wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-test-key"); expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", { type: "api_key", @@ -336,7 +336,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-test-key"); expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-test-key" }); - wrapped.clearApiKey("anthropic-api-key"); + await wrapped.clearApiKey("anthropic-api-key"); expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic"); expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false); @@ -359,7 +359,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-new-key"); + await wrapped.setApiKey("anthropic-api-key", "sk-ant-api03-new-key"); const legacyReadCallIndex = fusionAuth.get.mock.calls.findIndex(([provider]) => provider === "anthropic"); const apiKeyWriteCallIndex = fusionAuth.set.mock.calls.findIndex(([provider]) => provider === "anthropic"); @@ -393,7 +393,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.clearApiKey("anthropic-api-key"); + await wrapped.clearApiKey("anthropic-api-key"); expect(fusionAuth.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" })); expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic"); @@ -453,8 +453,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); }); - describe("logout with fallback credentials", () => { - it("hides fallback credentials after logout", () => { + describe("logout with fallback credentials", async () => { + it("hides fallback credentials after logout", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, @@ -468,7 +468,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(merged.get("anthropic")).toEqual({ type: "api_key", key: "claude-access-token" }); // Log out - merged.logout("anthropic"); + await merged.logout("anthropic"); // After logout, fallback credentials are hidden expect(merged.has("anthropic")).toBe(false); @@ -476,14 +476,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(merged.get("anthropic")).toBeUndefined(); }); - it("does not resurrect fallback credentials on reload after logout", () => { + it("does not resurrect fallback credentials on reload after logout", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); // reload() should NOT bring back the fallback credential merged.reload(); @@ -492,7 +492,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(merged.hasAuth("anthropic")).toBe(false); }); - it("excludes logged-out providers from getAll()", () => { + it("excludes logged-out providers from getAll()", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, @@ -500,14 +500,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); const all = merged.getAll(); expect("anthropic" in all).toBe(false); expect("openrouter" in all).toBe(true); }); - it("excludes logged-out providers from list()", () => { + it("excludes logged-out providers from list()", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, @@ -515,7 +515,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); expect(merged.list()).not.toContain("anthropic"); expect(merged.list()).toContain("openrouter"); @@ -531,28 +531,28 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(await merged.getApiKey("anthropic")).toBe("claude-access-token"); - merged.logout("anthropic"); + await merged.logout("anthropic"); expect(await merged.getApiKey("anthropic")).toBeUndefined(); }); - it("re-enables fallback credentials after re-authentication via set()", () => { + it("re-enables fallback credentials after re-authentication via set()", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); // Re-authenticate - merged.set("anthropic", { type: "api_key", key: "new-key" }); + await merged.set("anthropic", { type: "api_key", key: "new-key" }); // Provider is visible again (from primary storage) expect(merged.has("anthropic")).toBe(true); }); - it("only hides the logged-out provider, not other fallback providers", () => { + it("only hides the logged-out provider, not other fallback providers", async () => { const fusionAuth = makeAuthStorage(); const fallbackAuth = makeAuthStorage({ anthropic: { type: "api_key", key: "claude-access-token" }, @@ -560,7 +560,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); // anthropic is hidden expect(merged.hasAuth("anthropic")).toBe(false); @@ -568,7 +568,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { expect(merged.hasAuth("openrouter")).toBe(true); }); - it("returns false for hasAuth even when underlying storage reports auth via env var", () => { + it("returns false for hasAuth even when underlying storage reports auth via env var", async () => { // Simulate the real AuthStorage which checks env vars in hasAuth const fusionAuth = makeAuthStorage(); fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true @@ -577,7 +577,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); - merged.logout("anthropic"); + await merged.logout("anthropic"); // Even though the underlying storage reports hasAuth=true (env var), // the logged-out provider must still return false @@ -586,7 +586,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { }); }); - describe("anthropic-subscription getApiKey delegation (FN-7576)", () => { + describe("anthropic-subscription getApiKey delegation (FN-7576)", async () => { /* FNXC:ProviderAuth 2026-07-05-09:15: These tests drive `wrapAuthStorageWithApiKeyProviders`/`mergeAuthStorageReads` directly against an instrumented fake engine `authStorage.getApiKey`, not a mocked dashboard/engine `AuthStorageLike`, per FN-7576's "fix the invariant, not the repro" requirement. They assert the wrapper actually DELEGATES the anthropic-subscription read to the real engine authStorage (so the refresh HTTP round trip in packages/engine/src/auth-storage.ts executes) rather than short-circuiting to a local static `Date.now() >= credential.expires` check. @@ -670,7 +670,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => { const modelRegistry = { getAll: vi.fn(() => []) } as any; const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); - wrapped.logout("anthropic-subscription"); + await wrapped.logout("anthropic-subscription"); fusionAuth.getApiKey.mockClear(); const apiKey = await wrapped.getApiKey("anthropic-subscription"); diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index c950bbce7b..75cfb8ac6a 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -207,7 +207,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise { } const apiKey = await prompts.prompt("Enter API key"); - providerAuth.setApiKey(selectedProvider, apiKey); + await providerAuth.setApiKey(selectedProvider, apiKey); console.log(`✓ Stored API key for ${selectedProvider}`); }); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 7dba544c21..45b813dd73 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -249,13 +249,13 @@ export interface AuthStorageLike { signal?: AbortSignal; }, ): Promise; - logout(provider: string): void; + logout(provider: string): Promise; /** Get providers that accept API keys (non-OAuth). Returns provider id and name. */ getApiKeyProviders?(): Array<{ id: string; name: string }>; /** Save an API key for a provider. Creates or overwrites the existing key. */ - setApiKey?(providerId: string, apiKey: string): void; + setApiKey?(providerId: string, apiKey: string): Promise; /** Remove the stored API key for a provider. No-op if not set. */ - clearApiKey?(providerId: string): void; + clearApiKey?(providerId: string): Promise; /** Check if a provider has an API key configured. */ hasApiKey?(providerId: string): boolean; /** Get the configured API key for usage providers. */ diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index e9052720a0..0843546716 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -1739,7 +1739,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { * Body: { provider: string } * Response: { success: true } */ - router.post("/auth/logout", (req, res) => { + router.post("/auth/logout", async (req, res) => { try { const { provider } = req.body; if (!provider || typeof provider !== "string") { @@ -1747,7 +1747,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { } const storage = getAuthStorage(); - storage.logout(toOauthCredentialProviderId(provider)); + await storage.logout(toOauthCredentialProviderId(provider)); clearUsageCache(); res.json({ success: true }); } catch (err: unknown) { @@ -1791,7 +1791,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest(`Unknown API key provider: ${provider}`); } - storage.setApiKey(provider, apiKey.trim()); + await storage.setApiKey(provider, apiKey.trim()); let modelsRefreshed: number | undefined; let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | undefined; @@ -1829,7 +1829,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { * Body: { provider: string } * Response: { success: true } */ - router.delete("/auth/api-key", (req, res) => { + router.delete("/auth/api-key", async (req, res) => { try { const { provider } = req.body; if (!provider || typeof provider !== "string") { @@ -1851,7 +1851,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw badRequest(`Unknown API key provider: ${provider}`); } - storage.clearApiKey(provider); + await storage.clearApiKey(provider); // No model refresh needed on delete: removing the key leaves nothing to sync. clearUsageCache(); res.json({ success: true }); diff --git a/packages/dashboard/src/routes/register-mesh-routes.ts b/packages/dashboard/src/routes/register-mesh-routes.ts index d6f988fe56..00a6427da9 100644 --- a/packages/dashboard/src/routes/register-mesh-routes.ts +++ b/packages/dashboard/src/routes/register-mesh-routes.ts @@ -381,11 +381,11 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => { const authStorage = createFusionAuthStorage(); for (const [providerId, credential] of Object.entries(applied.providerAuth)) { if (credential.type === "api_key" && credential.key) { - authStorage.set(providerId, { type: "api_key", key: credential.key }); + await authStorage.set(providerId, { type: "api_key", key: credential.key }); continue; } if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { - authStorage.set(providerId, { + await authStorage.set(providerId, { type: "oauth", access: credential.accessToken, refresh: credential.refreshToken, diff --git a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts index 730b5b0d80..32a34f309f 100644 --- a/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-inbound-routes.ts @@ -256,12 +256,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => { const receivedProviders: string[] = []; for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) { if (credential.type === "api_key" && credential.key) { - authStorage.set(providerId, { type: "api_key", key: credential.key }); + await authStorage.set(providerId, { type: "api_key", key: credential.key }); receivedProviders.push(providerId); continue; } if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { - authStorage.set(providerId, { + await authStorage.set(providerId, { type: "oauth", access: credential.accessToken, refresh: credential.refreshToken, diff --git a/packages/dashboard/src/routes/register-settings-sync-routes.ts b/packages/dashboard/src/routes/register-settings-sync-routes.ts index 54aefed3c6..1e51423e08 100644 --- a/packages/dashboard/src/routes/register-settings-sync-routes.ts +++ b/packages/dashboard/src/routes/register-settings-sync-routes.ts @@ -581,12 +581,12 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => { const syncedProviders: string[] = []; for (const [providerId, credential] of Object.entries(applied.providerAuth)) { if (credential.type === "api_key" && credential.key) { - authStorage.set(providerId, { type: "api_key", key: credential.key }); + await authStorage.set(providerId, { type: "api_key", key: credential.key }); syncedProviders.push(providerId); continue; } if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { - authStorage.set(providerId, { + await authStorage.set(providerId, { type: "oauth", access: credential.accessToken, refresh: credential.refreshToken, diff --git a/packages/engine/src/__tests__/auth-storage-concurrency.test.ts b/packages/engine/src/__tests__/auth-storage-concurrency.test.ts index 95e418de08..f502ca5f7d 100644 --- a/packages/engine/src/__tests__/auth-storage-concurrency.test.ts +++ b/packages/engine/src/__tests__/auth-storage-concurrency.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createFusionAuthStorage, getFusionAuthPath } from "../auth-storage.js"; +import lockfile from "proper-lockfile"; +import { createFusionAuthStorage, createFusionCredentialStore, getFusionAuthPath } from "../auth-storage.js"; /* FNXC:ProviderAuth 2026-07-07-00:00: @@ -48,16 +49,63 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () vi.restoreAllMocks(); }); + async function releaseHeldLockAfter(path: string, holdMs = 40): Promise { + const release = await lockfile.lock(path, { realpath: false }); + setTimeout(() => { void release(); }, holdMs); + } + + it("waits for an independently held lock before set persists the credential", async () => { + const storage = createFusionAuthStorage(); + const authPath = getFusionAuthPath(homeDir); + await releaseHeldLockAfter(authPath); + + await expect(storage.set("openrouter", { type: "api_key", key: "held-lock-key" })).resolves.toBeUndefined(); + + const onDisk = readAuthFile(homeDir); + expect((onDisk.openrouter as { type?: string } | undefined)?.type).toBe("api_key"); + const freshStorage = createFusionAuthStorage(); + expect(freshStorage.has("openrouter")).toBe(true); + }); + + it("waits for held locks before remove and CredentialStore.delete", async () => { + const storage = createFusionAuthStorage(); + const authPath = getFusionAuthPath(homeDir); + await storage.set("openrouter", { type: "api_key", key: "present" }); + await storage.set("groq", { type: "api_key", key: "present" }); + + await releaseHeldLockAfter(authPath); + await expect(storage.remove("openrouter")).resolves.toBeUndefined(); + + const credentialStore = createFusionCredentialStore(storage); + await releaseHeldLockAfter(authPath); + await expect(credentialStore.delete("groq")).resolves.toBeUndefined(); + + const onDisk = readAuthFile(homeDir); + expect(onDisk.openrouter).toBeUndefined(); + expect(onDisk.groq).toBeUndefined(); + }); + + it("releases the per-path queue after a failed lock acquisition", async () => { + const storage = createFusionAuthStorage(); + const lockSpy = vi.spyOn(lockfile, "lock").mockRejectedValueOnce(new Error("injected lock failure")); + + await expect(storage.set("openrouter", { type: "api_key", key: "fails" })).rejects.toThrow("injected lock failure"); + lockSpy.mockRestore(); + await expect(storage.set("openrouter", { type: "api_key", key: "succeeds" })).resolves.toBeUndefined(); + + expect(storage.has("openrouter")).toBe(true); + }); + it("survives an unrelated oauth set from a second instance (missing-file baseline)", async () => { // Baseline: no auth.json exists yet when both instances are constructed. const instanceA = createFusionAuthStorage(); - instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); - instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); + await instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); + await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); // Instance B (e.g. the desktop app) constructs its own storage AFTER A's writes // are already on disk, then writes an unrelated provider's OAuth credential. const instanceB = createFusionAuthStorage(); - instanceB.set("anthropic-subscription", { + await instanceB.set("anthropic-subscription", { type: "oauth", access: "desktop-access", refresh: "desktop-refresh", @@ -76,18 +124,18 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () it("survives instance B writing a NEW provider after loading a stale snapshot", async () => { const instanceA = createFusionAuthStorage(); - instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); + await instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); // B constructs (and thus snapshots) BEFORE A's second write below. const instanceB = createFusionAuthStorage(); // A saves a new provider key while B is alive holding a stale in-memory snapshot — // this is the exact "web app writing while the desktop process is alive" scenario. - instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); + await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); // B performs its own write for a THIRD, different provider. Historically a // whole-file snapshot flush from B would wipe out A's mid-session write. - instanceB.set("groq", { type: "api_key", key: "desktop-groq-key" }); + await instanceB.set("groq", { type: "api_key", key: "desktop-groq-key" }); const onDisk = readAuthFile(homeDir); expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); @@ -102,15 +150,15 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () it("survives instance B's logout(\"anthropic\") for unrelated providers", async () => { const instanceA = createFusionAuthStorage(); - instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); - instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); + await instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); + await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); const instanceB = createFusionAuthStorage(); - instanceA.set("groq", { type: "api_key", key: "web-groq-key" }); + await instanceA.set("groq", { type: "api_key", key: "web-groq-key" }); // B logs out of a provider it never touched via A — this exercises the remove() // proxy trap's persistProviderChange path. - instanceB.logout("anthropic"); + await instanceB.logout("anthropic"); const onDisk = readAuthFile(homeDir); expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); @@ -130,18 +178,18 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () writeFileSync(getFusionAuthPath(homeDir), "{}"); const instanceA = createFusionAuthStorage(); - instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); + await instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); const instanceB = createFusionAuthStorage(); - instanceA.set("mistral", { type: "api_key", key: "web-mistral-key" }); + await instanceA.set("mistral", { type: "api_key", key: "web-mistral-key" }); - instanceB.set("anthropic-subscription", { + await instanceB.set("anthropic-subscription", { type: "oauth", access: "desktop-access", refresh: "desktop-refresh", expires: Date.now() + 3_600_000, }); - instanceB.remove("anthropic-subscription"); + await instanceB.remove("anthropic-subscription"); const onDisk = readAuthFile(homeDir); expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); @@ -170,7 +218,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () ); const instanceA = createFusionAuthStorage(); - instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); + await instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); // B constructs after A's write; construction runs syncSupplementalOauthCredentials(), // which itself calls primary.set() for the hydrated legacy OAuth provider — this is @@ -189,7 +237,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () // A logs in to Anthropic OAuth with a credential that is already due for refresh // (past the 5-minute proactive-refresh buffer). const instanceA = createFusionAuthStorage(); - instanceA.set("anthropic", { + await instanceA.set("anthropic", { type: "oauth", access: "old-access", refresh: "old-refresh", @@ -200,7 +248,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () const instanceB = createFusionAuthStorage(); // The user re-logs in via A with a fresh, long-lived credential AFTER B snapshotted. - instanceA.set("anthropic", { + await instanceA.set("anthropic", { type: "oauth", access: "new-access-from-relogin", refresh: "new-refresh-from-relogin", diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 44237644ee..42c25ca968 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -60,7 +60,7 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.set("openrouter", { type: "api_key", key: "fusion-openrouter-key" }); + await authStorage.set("openrouter", { type: "api_key", key: "fusion-openrouter-key" }); expect(await authStorage.getApiKey("openrouter")).toBe("fusion-openrouter-key"); expect(await authStorage.getApiKey("minimax")).toBe("legacy-minimax-key"); @@ -200,7 +200,7 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic")).toBe("fallback-anthropic-runtime-key"); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); expect(authStorage.hasAuth("anthropic")).toBe(false); expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); @@ -327,11 +327,11 @@ describe("createFusionAuthStorage", () => { // reporting "Login did not complete" despite a valid stored credential. const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); // `set` under the legacy id mirrors what interactive login persists. - authStorage.set("anthropic", { + await authStorage.set("anthropic", { type: "oauth", access: "relogin-access-token", refresh: "relogin-refresh-token", @@ -345,10 +345,10 @@ describe("createFusionAuthStorage", () => { it("restores the subscription card when re-auth writes under the subscription id", async () => { const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); - authStorage.set("anthropic-subscription", { + await authStorage.set("anthropic-subscription", { type: "oauth", access: "subscription-relogin-token", refresh: "subscription-relogin-refresh", @@ -363,8 +363,8 @@ describe("createFusionAuthStorage", () => { // the subscription's logged-out state — only OAuth credentials do. const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic-subscription"); - authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" }); + await authStorage.logout("anthropic-subscription"); + await authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" }); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); }); @@ -603,7 +603,7 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); // Raw-key logout removes only the raw slot; subscription OAuth still powers // the direct `anthropic` runtime provider. @@ -621,10 +621,10 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); - authStorage.set("anthropic-subscription", { + await authStorage.set("anthropic-subscription", { type: "oauth", access: "subscription-access-token", refresh: "subscription-refresh-token", @@ -648,7 +648,7 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); expect(authStorage.get("anthropic-subscription")).toBeUndefined(); expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key" }); @@ -669,7 +669,7 @@ describe("createFusionAuthStorage", () => { // Before logout the legacy OAuth row drives direct runtime auth… expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); // …and subscription logout suppresses the legacy OAuth alias everywhere. expect(authStorage.get("anthropic")).toBeUndefined(); @@ -699,7 +699,7 @@ describe("createFusionAuthStorage", () => { expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.list()).toContain("anthropic"); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); expect(authStorage.get("anthropic")).toBeUndefined(); expect(authStorage.has("anthropic")).toBe(false); @@ -726,7 +726,7 @@ describe("createFusionAuthStorage", () => { }); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic-subscription"); + await authStorage.logout("anthropic-subscription"); expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true); @@ -747,7 +747,7 @@ describe("createFusionAuthStorage", () => { expect(authStorage.has("anthropic")).toBe(true); expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key"); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); expect(authStorage.has("anthropic")).toBe(false); expect(authStorage.hasAuth("anthropic")).toBe(false); @@ -958,7 +958,7 @@ describe("createFusionAuthStorage", () => { const pendingRefresh = authStorage.getApiKey("anthropic"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); - authStorage.set("anthropic", { + await authStorage.set("anthropic", { type: "oauth", access: "fresh-login-access-token", refresh: "fresh-login-refresh-token", @@ -1235,7 +1235,7 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); // Log out - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); // After logout, supplemental credentials are hidden expect(authStorage.has("anthropic")).toBe(false); @@ -1259,7 +1259,7 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); // reload() should NOT bring back the supplemental credential authStorage.reload(); @@ -1284,7 +1284,7 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); const all = authStorage.getAll(); expect("anthropic" in all).toBe(false); @@ -1305,7 +1305,7 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); expect(authStorage.list()).not.toContain("anthropic"); }); @@ -1325,10 +1325,10 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); // Re-authenticate - authStorage.set("anthropic", { type: "api_key", key: "new-key" }); + await authStorage.set("anthropic", { type: "api_key", key: "new-key" }); // Provider is visible again expect(authStorage.has("anthropic")).toBe(true); @@ -1359,7 +1359,7 @@ describe("createFusionAuthStorage", () => { ); const authStorage = createFusionAuthStorage(); - authStorage.logout("anthropic"); + await authStorage.logout("anthropic"); // anthropic is hidden expect(authStorage.hasAuth("anthropic")).toBe(false); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 0ed83e7c61..6099e7dde1 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -1,6 +1,6 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import lockfile from "proper-lockfile"; import { choosePreferredStoredCredential, @@ -22,9 +22,9 @@ export interface FusionAuthStorage { list(): string[]; has(provider: string): boolean; hasAuth(provider: string): boolean; - set(provider: string, credential: StoredCredential): void; - remove(provider: string): void; - logout(provider: string): void; + set(provider: string, credential: StoredCredential): Promise; + remove(provider: string): Promise; + logout(provider: string): Promise; getApiKey(provider: string): Promise; getOAuthProviders(): Array<{ id: string; name: string }>; login(provider: string, callbacks: unknown): Promise; @@ -32,6 +32,42 @@ export interface FusionAuthStorage { setModelRuntime(modelRuntime: ModelRuntime): void; } +/* +FNXC:ProviderAuth 2026-07-17-08:30: +FN-8205 replaces the immediate-fail `lockSync` write path: proper-lockfile rejects +synchronous retries with ESYNC, so a held shared auth.json lock used to surface ELOCKED +straight through set/remove/logout. Writes now share this asynchronous retry policy with +modify() and queue per resolved auth path before taking the cross-process file lock. This +prevents same-engine sessions from self-contending while preserving fresh read-modify-merge +under the lock for independent Fusion processes. The vendored pi-coding-agent synchronous +lock defect from Runfusion/Fusion#2167 remains a separately tracked upstream follow-up. +*/ +const AUTH_LOCK_OPTIONS = { + realpath: false, + stale: 30_000, + retries: { + retries: 10, + factor: 2, + minTimeout: 20, + maxTimeout: 10_000, + randomize: true, + }, +} as const; + +const authWriteQueues = new Map>(); + +function enqueueAuthWrite(authPath: string, write: () => Promise): Promise { + const queueKey = resolve(authPath); + const previous = authWriteQueues.get(queueKey) ?? Promise.resolve(); + const operation = previous.catch(() => undefined).then(write); + const tail = operation.then(() => undefined, () => undefined); + authWriteQueues.set(queueKey, tail); + void tail.finally(() => { + if (authWriteQueues.get(queueKey) === tail) authWriteQueues.delete(queueKey); + }); + return operation; +} + class FusionFileAuthStorage implements FusionAuthStorage { private data: Record = {}; private modelRuntime: ModelRuntime | undefined; @@ -57,20 +93,22 @@ class FusionFileAuthStorage implements FusionAuthStorage { } } - private withLock(fn: (current: Record) => T): T { - this.ensureFile(); - // proper-lockfile's synchronous API cannot retry; writes remain serialized by the lock. - const release = lockfile.lockSync(this.authPath, { realpath: false }); - try { - const current = this.readCurrent(); - const result = fn(current); - this.data = current; - writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); - chmodSync(this.authPath, 0o600); - return result; - } finally { - release(); - } + private async withLock(fn: (current: Record) => Promise): Promise { + return enqueueAuthWrite(this.authPath, async () => { + this.ensureFile(); + const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS); + try { + // Always merge the on-disk state observed after acquiring the lock, never this.data. + const current = this.readCurrent(); + const result = await fn(current); + writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); + chmodSync(this.authPath, 0o600); + this.data = current; + return result; + } finally { + await release(); + } + }); } reload(): void { @@ -83,13 +121,13 @@ class FusionFileAuthStorage implements FusionAuthStorage { list(): string[] { return Object.keys(this.data); } has(provider: string): boolean { return Boolean(this.data[provider]); } hasAuth(provider: string): boolean { return this.has(provider); } - set(provider: string, credential: StoredCredential): void { - this.withLock((current) => { current[provider] = credential; }); + async set(provider: string, credential: StoredCredential): Promise { + await this.withLock(async (current) => { current[provider] = credential; }); } - remove(provider: string): void { - this.withLock((current) => { delete current[provider]; }); + async remove(provider: string): Promise { + await this.withLock(async (current) => { delete current[provider]; }); } - logout(provider: string): void { this.remove(provider); } + async logout(provider: string): Promise { await this.remove(provider); } async getApiKey(provider: string): Promise { return resolveStoredCredentialApiKey(provider, this.get(provider)); } @@ -128,19 +166,11 @@ class FusionFileAuthStorage implements FusionAuthStorage { this.reload(); } async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise): Promise { - this.ensureFile(); - const release = await lockfile.lock(this.authPath, { realpath: false, retries: { retries: 10, minTimeout: 20 } }); - try { - const current = this.readCurrent(); + return this.withLock(async (current) => { const next = await fn(current[provider]); if (next !== undefined) current[provider] = next; - this.data = current; - writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); - chmodSync(this.authPath, 0o600); return current[provider]; - } finally { - await release(); - } + }); } } @@ -164,7 +194,7 @@ export function createFusionCredentialStore(authStorage: FusionAuthStorage): Cre : []; }), modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise) as Promise, - delete: async (providerId) => { authStorage.remove(providerId); }, + delete: async (providerId) => { await authStorage.remove(providerId); }, }; } @@ -433,6 +463,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { */ const oauthRefreshInFlight = new Map>(); const oauthRefreshCooldownUntil = new Map(); + let supplementalHydration = Promise.resolve(); // Providers the user has explicitly logged out from. These should not be // "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json). @@ -459,7 +490,14 @@ export function createFusionAuthStorage(): FusionAuthStorage { } }; - const syncSupplementalOauthCredentials = () => { + /* + FNXC:ProviderAuth 2026-07-17-08:45: + Supplemental hydration persists through the same per-path queue as direct writes. Construction + and lock-free reload intentionally start it without blocking reads; callers that require a + hydrated credential use getApiKey(), which awaits its own persistence. Other reads observe + hydrated state after this queue drains rather than racing an un-awaited file write. + */ + const syncSupplementalOauthCredentials = async (): Promise => { for (const [provider, credential] of Object.entries(supplementalCredentials)) { if (loggedOutProviders.has(provider)) { continue; @@ -472,11 +510,11 @@ export function createFusionAuthStorage(): FusionAuthStorage { if (typeof credential.expires !== "number" || Date.now() >= credential.expires) { continue; } - primary.set(provider, credential as StoredCredential); + await primary.set(provider, credential as StoredCredential); continue; } if (credential.type === "api_key") { - primary.set(provider, credential as StoredCredential); + await primary.set(provider, credential as StoredCredential); } } }; @@ -650,7 +688,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { return resolveStoredCredentialApiKey(storageProvider, latestCredential); } } - primary.set(storageProvider, refreshedCredential as StoredCredential); + await primary.set(storageProvider, refreshedCredential as StoredCredential); loggedOutProviders.delete(storageProvider); return resolveStoredCredentialApiKey(storageProvider, refreshedCredential); } @@ -706,7 +744,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { return undefined; }; - syncSupplementalOauthCredentials(); + supplementalHydration = syncSupplementalOauthCredentials(); return new Proxy(primary, { // Forward property writes to the target so that methods like @@ -720,26 +758,26 @@ export function createFusionAuthStorage(): FusionAuthStorage { get(target, prop, receiver) { if (prop === "logout") { - return (provider: string) => { - target.logout(provider); + return async (provider: string): Promise => { + await target.logout(provider); loggedOutProviders.add(provider); if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined; if (legacyAnthropicCredential?.type === "oauth") { - target.logout(ANTHROPIC_PROVIDER_ID); + await target.logout(ANTHROPIC_PROVIDER_ID); } } }; } if (prop === "remove") { - return (provider: string) => { - target.remove(provider); + return async (provider: string): Promise => { + await target.remove(provider); loggedOutProviders.add(provider); if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined; if (legacyAnthropicCredential?.type === "oauth") { - target.remove(ANTHROPIC_PROVIDER_ID); + await target.remove(ANTHROPIC_PROVIDER_ID); } } }; @@ -771,8 +809,8 @@ export function createFusionAuthStorage(): FusionAuthStorage { } if (prop === "set") { - return (provider: string, credential: StoredCredential) => { - target.set(provider, credential); + return async (provider: string, credential: StoredCredential): Promise => { + await target.set(provider, credential); clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type); }; } @@ -781,7 +819,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { return () => { target.reload(); supplementalCredentials = readSupplementalCredentials(); - syncSupplementalOauthCredentials(); + supplementalHydration = syncSupplementalOauthCredentials(); modelsJsonApiKeys = readModelsJsonApiKeys(); }; } @@ -873,6 +911,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { if (prop === "getApiKey") { return async (provider: string) => { + await supplementalHydration; if (provider === ANTHROPIC_PROVIDER_ID) { return resolveAnthropicRuntimeApiKey(); } @@ -891,7 +930,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { FNXC:ProviderAuth 2026-07-01-12:34: Legacy Anthropic OAuth rows are subscription credentials, not raw API keys. Hydrate them into `anthropic-subscription` before refresh so status, usage, and banner clearing share the same provider id without overwriting a raw `anthropic` API-key credential. */ - primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as StoredCredential); + await primary.set(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential as StoredCredential); loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); } return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential); diff --git a/packages/engine/src/provider-auth.ts b/packages/engine/src/provider-auth.ts index 0d98a50639..62e49ec7b4 100644 --- a/packages/engine/src/provider-auth.ts +++ b/packages/engine/src/provider-auth.ts @@ -35,10 +35,10 @@ export interface DashboardAuthStorage { getOAuthProviders(): Array<{ id: string; name: string }>; hasAuth(provider: string): boolean; login(providerId: string, callbacks: LoginCallbacks): Promise; - logout(provider: string): void; + logout(provider: string): Promise; getApiKeyProviders(): Array<{ id: string; name: string }>; - setApiKey(providerId: string, apiKey: string): void; - clearApiKey(providerId: string): void; + setApiKey(providerId: string, apiKey: string): Promise; + clearApiKey(providerId: string): Promise; hasApiKey(providerId: string): boolean; getApiKey(providerId: string): Promise; get(providerId: string): { type?: string; key?: string } | undefined; @@ -105,7 +105,7 @@ export function wrapAuthStorageWithApiKeyProviders( return legacyCredential?.type === "oauth" ? legacyCredential : undefined; }; - const migrateStoredAnthropicSubscriptionCredential = () => { + const migrateStoredAnthropicSubscriptionCredential = async () => { const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined; if (existingSubscription?.type === "oauth") { return existingSubscription; @@ -121,7 +121,7 @@ export function wrapAuthStorageWithApiKeyProviders( Saving or clearing the separated `anthropic-api-key` provider overwrites the raw `anthropic` storage slot used by model execution. Read the primary auth storage directly and migrate legacy subscription OAuth from `anthropic` to `anthropic-subscription` before that write, because merged Anthropic reads intentionally expose `anthropic` as API-key-only. */ - mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as StoredCredential); + await mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, legacySubscription as StoredCredential); return legacySubscription; }; @@ -157,20 +157,20 @@ export function wrapAuthStorageWithApiKeyProviders( Anthropic subscription OAuth and raw Anthropic API-key auth must be separate UI providers: OAuth stays `anthropic`, while the UI/API key card uses `anthropic-api-key` and maps back to the `anthropic` model credential. Store subscription OAuth under an internal key after upstream login because the OAuth library writes through the same `anthropic` id used by model API-key execution. */ - mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as StoredCredential); + await mergedAuthStorage.set(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID, oauthCredential as StoredCredential); if (existingApiKey?.type === "api_key") { - mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as StoredCredential); + await mergedAuthStorage.set(ANTHROPIC_STORAGE_PROVIDER_ID, existingApiKey as StoredCredential); } else { - authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID); + await authStorage.remove(ANTHROPIC_STORAGE_PROVIDER_ID); } } }, - logout: (provider) => { + logout: async (provider) => { if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) { - mergedAuthStorage.logout(provider); + await mergedAuthStorage.logout(provider); return; } - mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); + await mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); /* FNXC:ProviderAuth 2026-06-29-23:59: Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`. @@ -178,7 +178,7 @@ export function wrapAuthStorageWithApiKeyProviders( */ const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined; if (legacyAnthropicCredential?.type === "oauth") { - mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID); + await mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID); } }, getApiKeyProviders: () => { @@ -215,19 +215,19 @@ export function wrapAuthStorageWithApiKeyProviders( a.name.localeCompare(b.name), ); }, - setApiKey: (providerId, apiKey) => { + setApiKey: async (providerId, apiKey) => { const storageProviderId = toApiKeyStorageProviderId(providerId); if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { - migrateStoredAnthropicSubscriptionCredential(); + await migrateStoredAnthropicSubscriptionCredential(); } - mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey }); + await mergedAuthStorage.set(storageProviderId, { type: "api_key", key: apiKey }); }, - clearApiKey: (providerId) => { + clearApiKey: async (providerId) => { const storageProviderId = toApiKeyStorageProviderId(providerId); if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { - migrateStoredAnthropicSubscriptionCredential(); + await migrateStoredAnthropicSubscriptionCredential(); } - mergedAuthStorage.remove(storageProviderId); + await mergedAuthStorage.remove(storageProviderId); }, hasApiKey: (providerId) => { const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId)); @@ -303,7 +303,7 @@ export function mergeAuthStorageReads( return selectCredential(providerId, readAuthStorages); }; - const syncFallbackOauthCredentials = () => { + const syncFallbackOauthCredentials = async (): Promise => { const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list())); for (const providerId of providerIds) { const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID @@ -322,32 +322,32 @@ export function mergeAuthStorageReads( FNXC:ProviderAuth 2026-06-29-23:48: Legacy Anthropic OAuth files may still store subscription credentials under `anthropic`; hydrate those as `anthropic-subscription` so Anthropic model/API-key reads only trust `api_key` credentials under `anthropic`. */ - authStorage.set(storageProviderId, candidate as StoredCredential); + await authStorage.set(storageProviderId, candidate as StoredCredential); } } }; - syncFallbackOauthCredentials(); + void syncFallbackOauthCredentials(); return new Proxy(authStorage, { get(target, prop, receiver) { if (prop === "logout") { - return (provider: string) => { - target.logout(provider); + return async (provider: string): Promise => { + await target.logout(provider); loggedOutProviders.add(provider); }; } if (prop === "remove") { - return (provider: string) => { - target.remove(provider); + return async (provider: string): Promise => { + await target.remove(provider); loggedOutProviders.add(provider); }; } if (prop === "set") { - return (provider: string, credential: StoredCredential) => { - target.set(provider, credential); + return async (provider: string, credential: StoredCredential): Promise => { + await target.set(provider, credential); loggedOutProviders.delete(provider); }; } @@ -357,7 +357,7 @@ export function mergeAuthStorageReads( for (const storage of readAuthStorages) { storage.reload(); } - syncFallbackOauthCredentials(); + void syncFallbackOauthCredentials(); }; }