FN-8205: fix shared auth credential lock retries

Serialize shared auth credential writes and await durable persistence under contention.

- Replace synchronous auth-file locking with queued asynchronous retries.
- Propagate asynchronous credential mutations through CLI, dashboard, and provider wrappers.
- Cover held-lock persistence, queue recovery, and awaited caller behavior.

Files changed:
 .changeset/fn-8205-auth-lock-retry.md              |   7 ++
 .../cli/src/commands/__tests__/onboard.test.ts     |  17 +++
 .../src/commands/__tests__/provider-auth.test.ts   |  78 ++++++------
 packages/cli/src/commands/onboard.ts               |   2 +-
 packages/dashboard/src/routes.ts                   |   6 +-
 .../dashboard/src/routes/register-auth-routes.ts   |  10 +-
 .../dashboard/src/routes/register-mesh-routes.ts   |   4 +-
 .../register-settings-sync-inbound-routes.ts       |   4 +-
 .../src/routes/register-settings-sync-routes.ts    |   4 +-
 .../src/__tests__/auth-storage-concurrency.test.ts |  84 ++++++++++---
 packages/engine/src/__tests__/auth-storage.test.ts |  48 ++++----
 packages/engine/src/auth-storage.ts                | 137 +++++++++++++--------
 packages/engine/src/provider-auth.ts               |  56 ++++-----
 13 files changed, 284 insertions(+), 173 deletions(-)

Fusion-Task-Id: FN-8205

Fusion-Task-Lineage: b932b9ce-9aee-4f0a-9155-63b026c6bde4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 09:47:40 -07:00
parent 4293826b5a
commit 7fc4b439ee
13 changed files with 284 additions and 173 deletions

View File

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

View File

@@ -154,6 +154,23 @@ describe("onboard", () => {
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Central DB already exists")); 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 () => { it("stores API key, runs init, persists global testMode and completion marker", async () => {
const providerAuth = makeProviderAuth(); const providerAuth = makeProviderAuth();
mockProviderAuthFactory.mockReturnValue(providerAuth); mockProviderAuthFactory.mockReturnValue(providerAuth);

View File

@@ -26,7 +26,7 @@ function makeAuthStorage(credentials: Record<string, { type: string; key?: strin
} as any; } as any;
} }
describe("wrapAuthStorageWithApiKeyProviders", () => { describe("wrapAuthStorageWithApiKeyProviders", async () => {
it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => { it("reads API keys from Fusion auth first and legacy auth fallbacks second", async () => {
const fusionAuth = makeAuthStorage({ const fusionAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "fusion-key" }, 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" }); 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 fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage({ const legacyAuth = makeAuthStorage({
openrouter: { type: "api_key", key: "legacy-key" }, openrouter: { type: "api_key", key: "legacy-key" },
@@ -53,13 +53,13 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry, [legacyAuth]); 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(fusionAuth.set).toHaveBeenCalledWith("openrouter", { type: "api_key", key: "fusion-key" });
expect(legacyAuth.set).not.toHaveBeenCalled(); 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 fusionAuth = makeAuthStorage();
const legacyAuth = makeAuthStorage(); const legacyAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -87,7 +87,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.list()).toEqual(expect.arrayContaining(["openrouter", "minimax"])); 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 fusionAuth = makeAuthStorage();
const modelRegistry = { const modelRegistry = {
getAll: vi.fn(() => [ getAll: vi.fn(() => [
@@ -103,7 +103,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).not.toContain("pi-claude-cli"); 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 fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -114,7 +114,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("tavily"); 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 fusionAuth = makeAuthStorage();
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
@@ -124,7 +124,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(providerIds).toContain("opencode-go"); 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(); const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [ fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic OAuth" }, { id: "anthropic", name: "Anthropic OAuth" },
@@ -182,8 +182,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await storage.getApiKey("anthropic")).toBeUndefined(); expect(await storage.getApiKey("anthropic")).toBeUndefined();
}); });
describe("Anthropic provider classification", () => { describe("Anthropic provider classification", async () => {
it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", () => { it("exposes Anthropic subscription OAuth under anthropic and API-key auth under a separate alias", async () => {
const fusionAuth = makeAuthStorage(); const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [ fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "anthropic", name: "Anthropic" }, { id: "anthropic", name: "Anthropic" },
@@ -203,7 +203,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(apiKeyProviders).toContainEqual({ id: "anthropic-api-key", name: "Anthropic API Key" }); 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(); const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [ fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "openai-codex", name: "OpenAI Codex" }, { id: "openai-codex", name: "OpenAI Codex" },
@@ -219,7 +219,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(apiKeyProviders.some((p) => p.id === "openai-codex")).toBe(false); 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(); const fusionAuth = makeAuthStorage();
fusionAuth.getOAuthProviders = vi.fn(() => [ fusionAuth.getOAuthProviders = vi.fn(() => [
{ id: "openai", name: "OpenAI OAuth" }, { id: "openai", name: "OpenAI OAuth" },
@@ -265,7 +265,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-existing"); 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({ const fusionAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "sk-ant-api03-existing" }, anthropic: { type: "api_key", key: "sk-ant-api03-existing" },
"anthropic-subscription": { "anthropic-subscription": {
@@ -281,14 +281,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.logout("anthropic-subscription"); await wrapped.logout("anthropic-subscription");
expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription"); expect(fusionAuth.logout).toHaveBeenCalledWith("anthropic-subscription");
expect(fusionAuth.logout).not.toHaveBeenCalledWith("anthropic"); expect(fusionAuth.logout).not.toHaveBeenCalledWith("anthropic");
expect(wrapped.get("anthropic-api-key")).toEqual({ type: "api_key", key: "sk-ant-api03-existing" }); 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({ const fusionAuth = makeAuthStorage({
anthropic: { anthropic: {
type: "oauth", type: "oauth",
@@ -305,7 +305,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
expect(wrapped.get("anthropic")?.type).toBe("oauth"); expect(wrapped.get("anthropic")?.type).toBe("oauth");
wrapped.logout("anthropic"); await wrapped.logout("anthropic");
wrapped.reload(); wrapped.reload();
expect(fusionAuth.get).toHaveBeenCalledWith("anthropic"); expect(fusionAuth.get).toHaveBeenCalledWith("anthropic");
@@ -324,7 +324,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); 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", { expect(fusionAuth.set).toHaveBeenCalledWith("anthropic", {
type: "api_key", type: "api_key",
@@ -336,7 +336,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await wrapped.getApiKey("anthropic")).toBe("sk-ant-api03-test-key"); 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" }); 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(fusionAuth.remove).toHaveBeenCalledWith("anthropic");
expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false); expect(wrapped.hasApiKey("anthropic-api-key")).toBe(false);
@@ -359,7 +359,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
const modelRegistry = { getAll: vi.fn(() => []) } as any; const modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); 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 legacyReadCallIndex = fusionAuth.get.mock.calls.findIndex(([provider]) => provider === "anthropic");
const apiKeyWriteCallIndex = fusionAuth.set.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 modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); 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.set).toHaveBeenCalledWith("anthropic-subscription", expect.objectContaining({ type: "oauth" }));
expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic"); expect(fusionAuth.remove).toHaveBeenCalledWith("anthropic");
@@ -453,8 +453,8 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
}); });
}); });
describe("logout with fallback credentials", () => { describe("logout with fallback credentials", async () => {
it("hides fallback credentials after logout", () => { it("hides fallback credentials after logout", async () => {
const fusionAuth = makeAuthStorage(); const fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, 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" }); expect(merged.get("anthropic")).toEqual({ type: "api_key", key: "claude-access-token" });
// Log out // Log out
merged.logout("anthropic"); await merged.logout("anthropic");
// After logout, fallback credentials are hidden // After logout, fallback credentials are hidden
expect(merged.has("anthropic")).toBe(false); expect(merged.has("anthropic")).toBe(false);
@@ -476,14 +476,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.get("anthropic")).toBeUndefined(); 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 fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, anthropic: { type: "api_key", key: "claude-access-token" },
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
// reload() should NOT bring back the fallback credential // reload() should NOT bring back the fallback credential
merged.reload(); merged.reload();
@@ -492,7 +492,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.hasAuth("anthropic")).toBe(false); 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 fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, anthropic: { type: "api_key", key: "claude-access-token" },
@@ -500,14 +500,14 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
const all = merged.getAll(); const all = merged.getAll();
expect("anthropic" in all).toBe(false); expect("anthropic" in all).toBe(false);
expect("openrouter" in all).toBe(true); 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 fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, anthropic: { type: "api_key", key: "claude-access-token" },
@@ -515,7 +515,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
expect(merged.list()).not.toContain("anthropic"); expect(merged.list()).not.toContain("anthropic");
expect(merged.list()).toContain("openrouter"); expect(merged.list()).toContain("openrouter");
@@ -531,28 +531,28 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(await merged.getApiKey("anthropic")).toBe("claude-access-token"); expect(await merged.getApiKey("anthropic")).toBe("claude-access-token");
merged.logout("anthropic"); await merged.logout("anthropic");
expect(await merged.getApiKey("anthropic")).toBeUndefined(); 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 fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, anthropic: { type: "api_key", key: "claude-access-token" },
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
// Re-authenticate // 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) // Provider is visible again (from primary storage)
expect(merged.has("anthropic")).toBe(true); 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 fusionAuth = makeAuthStorage();
const fallbackAuth = makeAuthStorage({ const fallbackAuth = makeAuthStorage({
anthropic: { type: "api_key", key: "claude-access-token" }, anthropic: { type: "api_key", key: "claude-access-token" },
@@ -560,7 +560,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
// anthropic is hidden // anthropic is hidden
expect(merged.hasAuth("anthropic")).toBe(false); expect(merged.hasAuth("anthropic")).toBe(false);
@@ -568,7 +568,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
expect(merged.hasAuth("openrouter")).toBe(true); 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 // Simulate the real AuthStorage which checks env vars in hasAuth
const fusionAuth = makeAuthStorage(); const fusionAuth = makeAuthStorage();
fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true fusionAuth.hasAuth = vi.fn(() => true); // env var would make this true
@@ -577,7 +577,7 @@ describe("wrapAuthStorageWithApiKeyProviders", () => {
}); });
const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]); const merged = mergeAuthStorageReads(fusionAuth, [fallbackAuth]);
merged.logout("anthropic"); await merged.logout("anthropic");
// Even though the underlying storage reports hasAuth=true (env var), // Even though the underlying storage reports hasAuth=true (env var),
// the logged-out provider must still return false // 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: 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. 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 modelRegistry = { getAll: vi.fn(() => []) } as any;
const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry); const wrapped = wrapAuthStorageWithApiKeyProviders(fusionAuth, modelRegistry);
wrapped.logout("anthropic-subscription"); await wrapped.logout("anthropic-subscription");
fusionAuth.getApiKey.mockClear(); fusionAuth.getApiKey.mockClear();
const apiKey = await wrapped.getApiKey("anthropic-subscription"); const apiKey = await wrapped.getApiKey("anthropic-subscription");

View File

@@ -207,7 +207,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
} }
const apiKey = await prompts.prompt("Enter API key"); const apiKey = await prompts.prompt("Enter API key");
providerAuth.setApiKey(selectedProvider, apiKey); await providerAuth.setApiKey(selectedProvider, apiKey);
console.log(`✓ Stored API key for ${selectedProvider}`); console.log(`✓ Stored API key for ${selectedProvider}`);
}); });

View File

@@ -249,13 +249,13 @@ export interface AuthStorageLike {
signal?: AbortSignal; signal?: AbortSignal;
}, },
): Promise<void>; ): Promise<void>;
logout(provider: string): void; logout(provider: string): Promise<void>;
/** Get providers that accept API keys (non-OAuth). Returns provider id and name. */ /** Get providers that accept API keys (non-OAuth). Returns provider id and name. */
getApiKeyProviders?(): Array<{ id: string; name: string }>; getApiKeyProviders?(): Array<{ id: string; name: string }>;
/** Save an API key for a provider. Creates or overwrites the existing key. */ /** 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<void>;
/** Remove the stored API key for a provider. No-op if not set. */ /** Remove the stored API key for a provider. No-op if not set. */
clearApiKey?(providerId: string): void; clearApiKey?(providerId: string): Promise<void>;
/** Check if a provider has an API key configured. */ /** Check if a provider has an API key configured. */
hasApiKey?(providerId: string): boolean; hasApiKey?(providerId: string): boolean;
/** Get the configured API key for usage providers. */ /** Get the configured API key for usage providers. */

View File

@@ -1739,7 +1739,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
* Body: { provider: string } * Body: { provider: string }
* Response: { success: true } * Response: { success: true }
*/ */
router.post("/auth/logout", (req, res) => { router.post("/auth/logout", async (req, res) => {
try { try {
const { provider } = req.body; const { provider } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
@@ -1747,7 +1747,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
const storage = getAuthStorage(); const storage = getAuthStorage();
storage.logout(toOauthCredentialProviderId(provider)); await storage.logout(toOauthCredentialProviderId(provider));
clearUsageCache(); clearUsageCache();
res.json({ success: true }); res.json({ success: true });
} catch (err: unknown) { } catch (err: unknown) {
@@ -1791,7 +1791,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`); throw badRequest(`Unknown API key provider: ${provider}`);
} }
storage.setApiKey(provider, apiKey.trim()); await storage.setApiKey(provider, apiKey.trim());
let modelsRefreshed: number | undefined; let modelsRefreshed: number | undefined;
let refreshReason: "no-models-from-cli" | "cli-failed" | "disabled-by-settings" | 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 } * Body: { provider: string }
* Response: { success: true } * Response: { success: true }
*/ */
router.delete("/auth/api-key", (req, res) => { router.delete("/auth/api-key", async (req, res) => {
try { try {
const { provider } = req.body; const { provider } = req.body;
if (!provider || typeof provider !== "string") { if (!provider || typeof provider !== "string") {
@@ -1851,7 +1851,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
throw badRequest(`Unknown API key provider: ${provider}`); 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. // No model refresh needed on delete: removing the key leaves nothing to sync.
clearUsageCache(); clearUsageCache();
res.json({ success: true }); res.json({ success: true });

View File

@@ -381,11 +381,11 @@ export const registerMeshRoutes: ApiRouteRegistrar = (ctx) => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
for (const [providerId, credential] of Object.entries(applied.providerAuth)) { for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
if (credential.type === "api_key" && credential.key) { 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; continue;
} }
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, { await authStorage.set(providerId, {
type: "oauth", type: "oauth",
access: credential.accessToken, access: credential.accessToken,
refresh: credential.refreshToken, refresh: credential.refreshToken,

View File

@@ -256,12 +256,12 @@ export const registerSettingsSyncInboundRoutes: ApiRouteRegistrar = (ctx) => {
const receivedProviders: string[] = []; const receivedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) { for (const [providerId, credential] of Object.entries(applyResult.providerAuth)) {
if (credential.type === "api_key" && credential.key) { 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); receivedProviders.push(providerId);
continue; continue;
} }
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, { await authStorage.set(providerId, {
type: "oauth", type: "oauth",
access: credential.accessToken, access: credential.accessToken,
refresh: credential.refreshToken, refresh: credential.refreshToken,

View File

@@ -581,12 +581,12 @@ export const registerSettingsSyncRoutes: ApiRouteRegistrar = (ctx) => {
const syncedProviders: string[] = []; const syncedProviders: string[] = [];
for (const [providerId, credential] of Object.entries(applied.providerAuth)) { for (const [providerId, credential] of Object.entries(applied.providerAuth)) {
if (credential.type === "api_key" && credential.key) { 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); syncedProviders.push(providerId);
continue; continue;
} }
if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") { if (credential.type === "oauth" && credential.accessToken && credential.refreshToken && typeof credential.expires === "number") {
authStorage.set(providerId, { await authStorage.set(providerId, {
type: "oauth", type: "oauth",
access: credential.accessToken, access: credential.accessToken,
refresh: credential.refreshToken, refresh: credential.refreshToken,

View File

@@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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: FNXC:ProviderAuth 2026-07-07-00:00:
@@ -48,16 +49,63 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
async function releaseHeldLockAfter(path: string, holdMs = 40): Promise<void> {
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 () => { 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. // Baseline: no auth.json exists yet when both instances are constructed.
const instanceA = createFusionAuthStorage(); const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-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 // 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. // are already on disk, then writes an unrelated provider's OAuth credential.
const instanceB = createFusionAuthStorage(); const instanceB = createFusionAuthStorage();
instanceB.set("anthropic-subscription", { await instanceB.set("anthropic-subscription", {
type: "oauth", type: "oauth",
access: "desktop-access", access: "desktop-access",
refresh: "desktop-refresh", 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 () => { it("survives instance B writing a NEW provider after loading a stale snapshot", async () => {
const instanceA = createFusionAuthStorage(); 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. // B constructs (and thus snapshots) BEFORE A's second write below.
const instanceB = createFusionAuthStorage(); const instanceB = createFusionAuthStorage();
// A saves a new provider key while B is alive holding a stale in-memory snapshot — // 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. // 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 // 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. // 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); const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); 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 () => { it("survives instance B's logout(\"anthropic\") for unrelated providers", async () => {
const instanceA = createFusionAuthStorage(); const instanceA = createFusionAuthStorage();
instanceA.set("openai", { type: "api_key", key: "web-openai-key" }); await instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" }); await instanceA.set("openrouter", { type: "api_key", key: "web-openrouter-key" });
const instanceB = createFusionAuthStorage(); 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() // B logs out of a provider it never touched via A — this exercises the remove()
// proxy trap's persistProviderChange path. // proxy trap's persistProviderChange path.
instanceB.logout("anthropic"); await instanceB.logout("anthropic");
const onDisk = readAuthFile(homeDir); const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
@@ -130,18 +178,18 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
writeFileSync(getFusionAuthPath(homeDir), "{}"); writeFileSync(getFusionAuthPath(homeDir), "{}");
const instanceA = createFusionAuthStorage(); 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(); 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", type: "oauth",
access: "desktop-access", access: "desktop-access",
refresh: "desktop-refresh", refresh: "desktop-refresh",
expires: Date.now() + 3_600_000, expires: Date.now() + 3_600_000,
}); });
instanceB.remove("anthropic-subscription"); await instanceB.remove("anthropic-subscription");
const onDisk = readAuthFile(homeDir); const onDisk = readAuthFile(homeDir);
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" }); expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
@@ -170,7 +218,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
); );
const instanceA = createFusionAuthStorage(); 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(), // B constructs after A's write; construction runs syncSupplementalOauthCredentials(),
// which itself calls primary.set() for the hydrated legacy OAuth provider — this is // 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 // A logs in to Anthropic OAuth with a credential that is already due for refresh
// (past the 5-minute proactive-refresh buffer). // (past the 5-minute proactive-refresh buffer).
const instanceA = createFusionAuthStorage(); const instanceA = createFusionAuthStorage();
instanceA.set("anthropic", { await instanceA.set("anthropic", {
type: "oauth", type: "oauth",
access: "old-access", access: "old-access",
refresh: "old-refresh", refresh: "old-refresh",
@@ -200,7 +248,7 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
const instanceB = createFusionAuthStorage(); const instanceB = createFusionAuthStorage();
// The user re-logs in via A with a fresh, long-lived credential AFTER B snapshotted. // 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", type: "oauth",
access: "new-access-from-relogin", access: "new-access-from-relogin",
refresh: "new-refresh-from-relogin", refresh: "new-refresh-from-relogin",

View File

@@ -60,7 +60,7 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = 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("openrouter")).toBe("fusion-openrouter-key");
expect(await authStorage.getApiKey("minimax")).toBe("legacy-minimax-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"); expect(await authStorage.getApiKey("anthropic")).toBe("fallback-anthropic-runtime-key");
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
expect(authStorage.hasAuth("anthropic")).toBe(false); expect(authStorage.hasAuth("anthropic")).toBe(false);
expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
@@ -327,11 +327,11 @@ describe("createFusionAuthStorage", () => {
// reporting "Login did not complete" despite a valid stored credential. // reporting "Login did not complete" despite a valid stored credential.
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
// `set` under the legacy id mirrors what interactive login persists. // `set` under the legacy id mirrors what interactive login persists.
authStorage.set("anthropic", { await authStorage.set("anthropic", {
type: "oauth", type: "oauth",
access: "relogin-access-token", access: "relogin-access-token",
refresh: "relogin-refresh-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 () => { it("restores the subscription card when re-auth writes under the subscription id", async () => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
authStorage.set("anthropic-subscription", { await authStorage.set("anthropic-subscription", {
type: "oauth", type: "oauth",
access: "subscription-relogin-token", access: "subscription-relogin-token",
refresh: "subscription-relogin-refresh", refresh: "subscription-relogin-refresh",
@@ -363,8 +363,8 @@ describe("createFusionAuthStorage", () => {
// the subscription's logged-out state — only OAuth credentials do. // the subscription's logged-out state — only OAuth credentials do.
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" }); await authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" });
expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); expect(authStorage.hasAuth("anthropic-subscription")).toBe(false);
}); });
@@ -603,7 +603,7 @@ describe("createFusionAuthStorage", () => {
}); });
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
// Raw-key logout removes only the raw slot; subscription OAuth still powers // Raw-key logout removes only the raw slot; subscription OAuth still powers
// the direct `anthropic` runtime provider. // the direct `anthropic` runtime provider.
@@ -621,10 +621,10 @@ describe("createFusionAuthStorage", () => {
}); });
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
expect(await authStorage.getApiKey("anthropic")).toBeUndefined(); expect(await authStorage.getApiKey("anthropic")).toBeUndefined();
authStorage.set("anthropic-subscription", { await authStorage.set("anthropic-subscription", {
type: "oauth", type: "oauth",
access: "subscription-access-token", access: "subscription-access-token",
refresh: "subscription-refresh-token", refresh: "subscription-refresh-token",
@@ -648,7 +648,7 @@ describe("createFusionAuthStorage", () => {
}); });
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
expect(authStorage.get("anthropic-subscription")).toBeUndefined(); expect(authStorage.get("anthropic-subscription")).toBeUndefined();
expect(authStorage.get("anthropic")).toEqual({ type: "api_key", key: "sk-ant-api03-runtime-key" }); 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… // Before logout the legacy OAuth row drives direct runtime auth…
expect(await authStorage.getApiKey("anthropic")).toBe("legacy-subscription-access-token"); 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. // …and subscription logout suppresses the legacy OAuth alias everywhere.
expect(authStorage.get("anthropic")).toBeUndefined(); expect(authStorage.get("anthropic")).toBeUndefined();
@@ -699,7 +699,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
expect(authStorage.list()).toContain("anthropic"); expect(authStorage.list()).toContain("anthropic");
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
expect(authStorage.get("anthropic")).toBeUndefined(); expect(authStorage.get("anthropic")).toBeUndefined();
expect(authStorage.has("anthropic")).toBe(false); expect(authStorage.has("anthropic")).toBe(false);
@@ -726,7 +726,7 @@ describe("createFusionAuthStorage", () => {
}); });
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic-subscription"); await authStorage.logout("anthropic-subscription");
expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.has("anthropic")).toBe(true);
expect(authStorage.hasAuth("anthropic")).toBe(true); expect(authStorage.hasAuth("anthropic")).toBe(true);
@@ -747,7 +747,7 @@ describe("createFusionAuthStorage", () => {
expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.has("anthropic")).toBe(true);
expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key"); expect(await authStorage.getApiKey("anthropic")).toBe("models-runtime-key");
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
expect(authStorage.has("anthropic")).toBe(false); expect(authStorage.has("anthropic")).toBe(false);
expect(authStorage.hasAuth("anthropic")).toBe(false); expect(authStorage.hasAuth("anthropic")).toBe(false);
@@ -958,7 +958,7 @@ describe("createFusionAuthStorage", () => {
const pendingRefresh = authStorage.getApiKey("anthropic"); const pendingRefresh = authStorage.getApiKey("anthropic");
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
authStorage.set("anthropic", { await authStorage.set("anthropic", {
type: "oauth", type: "oauth",
access: "fresh-login-access-token", access: "fresh-login-access-token",
refresh: "fresh-login-refresh-token", refresh: "fresh-login-refresh-token",
@@ -1235,7 +1235,7 @@ describe("createFusionAuthStorage", () => {
expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token"); expect(await authStorage.getApiKey("anthropic")).toBe("claude-access-token");
// Log out // Log out
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
// After logout, supplemental credentials are hidden // After logout, supplemental credentials are hidden
expect(authStorage.has("anthropic")).toBe(false); expect(authStorage.has("anthropic")).toBe(false);
@@ -1259,7 +1259,7 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
// reload() should NOT bring back the supplemental credential // reload() should NOT bring back the supplemental credential
authStorage.reload(); authStorage.reload();
@@ -1284,7 +1284,7 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
const all = authStorage.getAll(); const all = authStorage.getAll();
expect("anthropic" in all).toBe(false); expect("anthropic" in all).toBe(false);
@@ -1305,7 +1305,7 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
expect(authStorage.list()).not.toContain("anthropic"); expect(authStorage.list()).not.toContain("anthropic");
}); });
@@ -1325,10 +1325,10 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
// Re-authenticate // 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 // Provider is visible again
expect(authStorage.has("anthropic")).toBe(true); expect(authStorage.has("anthropic")).toBe(true);
@@ -1359,7 +1359,7 @@ describe("createFusionAuthStorage", () => {
); );
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
authStorage.logout("anthropic"); await authStorage.logout("anthropic");
// anthropic is hidden // anthropic is hidden
expect(authStorage.hasAuth("anthropic")).toBe(false); expect(authStorage.hasAuth("anthropic")).toBe(false);

View File

@@ -1,6 +1,6 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { import {
choosePreferredStoredCredential, choosePreferredStoredCredential,
@@ -22,9 +22,9 @@ export interface FusionAuthStorage {
list(): string[]; list(): string[];
has(provider: string): boolean; has(provider: string): boolean;
hasAuth(provider: string): boolean; hasAuth(provider: string): boolean;
set(provider: string, credential: StoredCredential): void; set(provider: string, credential: StoredCredential): Promise<void>;
remove(provider: string): void; remove(provider: string): Promise<void>;
logout(provider: string): void; logout(provider: string): Promise<void>;
getApiKey(provider: string): Promise<string | undefined>; getApiKey(provider: string): Promise<string | undefined>;
getOAuthProviders(): Array<{ id: string; name: string }>; getOAuthProviders(): Array<{ id: string; name: string }>;
login(provider: string, callbacks: unknown): Promise<void>; login(provider: string, callbacks: unknown): Promise<void>;
@@ -32,6 +32,42 @@ export interface FusionAuthStorage {
setModelRuntime(modelRuntime: ModelRuntime): void; 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<string, Promise<void>>();
function enqueueAuthWrite<T>(authPath: string, write: () => Promise<T>): Promise<T> {
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 { class FusionFileAuthStorage implements FusionAuthStorage {
private data: Record<string, StoredCredential> = {}; private data: Record<string, StoredCredential> = {};
private modelRuntime: ModelRuntime | undefined; private modelRuntime: ModelRuntime | undefined;
@@ -57,20 +93,22 @@ class FusionFileAuthStorage implements FusionAuthStorage {
} }
} }
private withLock<T>(fn: (current: Record<string, StoredCredential>) => T): T { private async withLock<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
this.ensureFile(); return enqueueAuthWrite(this.authPath, async () => {
// proper-lockfile's synchronous API cannot retry; writes remain serialized by the lock. this.ensureFile();
const release = lockfile.lockSync(this.authPath, { realpath: false }); const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
try { try {
const current = this.readCurrent(); // Always merge the on-disk state observed after acquiring the lock, never this.data.
const result = fn(current); const current = this.readCurrent();
this.data = current; const result = await fn(current);
writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600); chmodSync(this.authPath, 0o600);
return result; this.data = current;
} finally { return result;
release(); } finally {
} await release();
}
});
} }
reload(): void { reload(): void {
@@ -83,13 +121,13 @@ class FusionFileAuthStorage implements FusionAuthStorage {
list(): string[] { return Object.keys(this.data); } list(): string[] { return Object.keys(this.data); }
has(provider: string): boolean { return Boolean(this.data[provider]); } has(provider: string): boolean { return Boolean(this.data[provider]); }
hasAuth(provider: string): boolean { return this.has(provider); } hasAuth(provider: string): boolean { return this.has(provider); }
set(provider: string, credential: StoredCredential): void { async set(provider: string, credential: StoredCredential): Promise<void> {
this.withLock((current) => { current[provider] = credential; }); await this.withLock(async (current) => { current[provider] = credential; });
} }
remove(provider: string): void { async remove(provider: string): Promise<void> {
this.withLock((current) => { delete current[provider]; }); await this.withLock(async (current) => { delete current[provider]; });
} }
logout(provider: string): void { this.remove(provider); } async logout(provider: string): Promise<void> { await this.remove(provider); }
async getApiKey(provider: string): Promise<string | undefined> { async getApiKey(provider: string): Promise<string | undefined> {
return resolveStoredCredentialApiKey(provider, this.get(provider)); return resolveStoredCredentialApiKey(provider, this.get(provider));
} }
@@ -128,19 +166,11 @@ class FusionFileAuthStorage implements FusionAuthStorage {
this.reload(); this.reload();
} }
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> { async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
this.ensureFile(); return this.withLock(async (current) => {
const release = await lockfile.lock(this.authPath, { realpath: false, retries: { retries: 10, minTimeout: 20 } });
try {
const current = this.readCurrent();
const next = await fn(current[provider]); const next = await fn(current[provider]);
if (next !== undefined) current[provider] = next; 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]; 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<StoredCredential | undefined>) as Promise<Credential | undefined>, modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise<StoredCredential | undefined>) as Promise<Credential | undefined>,
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<string, Promise<StoredCredential | undefined>>(); const oauthRefreshInFlight = new Map<string, Promise<StoredCredential | undefined>>();
const oauthRefreshCooldownUntil = new Map<string, number>(); const oauthRefreshCooldownUntil = new Map<string, number>();
let supplementalHydration = Promise.resolve();
// Providers the user has explicitly logged out from. These should not be // Providers the user has explicitly logged out from. These should not be
// "resurrected" from supplemental credential files (e.g. ~/.claude/.credentials.json). // "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<void> => {
for (const [provider, credential] of Object.entries(supplementalCredentials)) { for (const [provider, credential] of Object.entries(supplementalCredentials)) {
if (loggedOutProviders.has(provider)) { if (loggedOutProviders.has(provider)) {
continue; continue;
@@ -472,11 +510,11 @@ export function createFusionAuthStorage(): FusionAuthStorage {
if (typeof credential.expires !== "number" || Date.now() >= credential.expires) { if (typeof credential.expires !== "number" || Date.now() >= credential.expires) {
continue; continue;
} }
primary.set(provider, credential as StoredCredential); await primary.set(provider, credential as StoredCredential);
continue; continue;
} }
if (credential.type === "api_key") { 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); return resolveStoredCredentialApiKey(storageProvider, latestCredential);
} }
} }
primary.set(storageProvider, refreshedCredential as StoredCredential); await primary.set(storageProvider, refreshedCredential as StoredCredential);
loggedOutProviders.delete(storageProvider); loggedOutProviders.delete(storageProvider);
return resolveStoredCredentialApiKey(storageProvider, refreshedCredential); return resolveStoredCredentialApiKey(storageProvider, refreshedCredential);
} }
@@ -706,7 +744,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return undefined; return undefined;
}; };
syncSupplementalOauthCredentials(); supplementalHydration = syncSupplementalOauthCredentials();
return new Proxy(primary, { return new Proxy(primary, {
// Forward property writes to the target so that methods like // Forward property writes to the target so that methods like
@@ -720,26 +758,26 @@ export function createFusionAuthStorage(): FusionAuthStorage {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === "logout") { if (prop === "logout") {
return (provider: string) => { return async (provider: string): Promise<void> => {
target.logout(provider); await target.logout(provider);
loggedOutProviders.add(provider); loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined; const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") { if (legacyAnthropicCredential?.type === "oauth") {
target.logout(ANTHROPIC_PROVIDER_ID); await target.logout(ANTHROPIC_PROVIDER_ID);
} }
} }
}; };
} }
if (prop === "remove") { if (prop === "remove") {
return (provider: string) => { return async (provider: string): Promise<void> => {
target.remove(provider); await target.remove(provider);
loggedOutProviders.add(provider); loggedOutProviders.add(provider);
if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) { if (provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID) {
const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined; const legacyAnthropicCredential = target.get(ANTHROPIC_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") { 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") { if (prop === "set") {
return (provider: string, credential: StoredCredential) => { return async (provider: string, credential: StoredCredential): Promise<void> => {
target.set(provider, credential); await target.set(provider, credential);
clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type); clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type);
}; };
} }
@@ -781,7 +819,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return () => { return () => {
target.reload(); target.reload();
supplementalCredentials = readSupplementalCredentials(); supplementalCredentials = readSupplementalCredentials();
syncSupplementalOauthCredentials(); supplementalHydration = syncSupplementalOauthCredentials();
modelsJsonApiKeys = readModelsJsonApiKeys(); modelsJsonApiKeys = readModelsJsonApiKeys();
}; };
} }
@@ -873,6 +911,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
if (prop === "getApiKey") { if (prop === "getApiKey") {
return async (provider: string) => { return async (provider: string) => {
await supplementalHydration;
if (provider === ANTHROPIC_PROVIDER_ID) { if (provider === ANTHROPIC_PROVIDER_ID) {
return resolveAnthropicRuntimeApiKey(); return resolveAnthropicRuntimeApiKey();
} }
@@ -891,7 +930,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
FNXC:ProviderAuth 2026-07-01-12:34: 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. 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); loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
} }
return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential); return resolveRefreshableCredentialApiKey(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, subscriptionCredential);

View File

@@ -35,10 +35,10 @@ export interface DashboardAuthStorage {
getOAuthProviders(): Array<{ id: string; name: string }>; getOAuthProviders(): Array<{ id: string; name: string }>;
hasAuth(provider: string): boolean; hasAuth(provider: string): boolean;
login(providerId: string, callbacks: LoginCallbacks): Promise<void>; login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
logout(provider: string): void; logout(provider: string): Promise<void>;
getApiKeyProviders(): Array<{ id: string; name: string }>; getApiKeyProviders(): Array<{ id: string; name: string }>;
setApiKey(providerId: string, apiKey: string): void; setApiKey(providerId: string, apiKey: string): Promise<void>;
clearApiKey(providerId: string): void; clearApiKey(providerId: string): Promise<void>;
hasApiKey(providerId: string): boolean; hasApiKey(providerId: string): boolean;
getApiKey(providerId: string): Promise<string | undefined>; getApiKey(providerId: string): Promise<string | undefined>;
get(providerId: string): { type?: string; key?: string } | undefined; get(providerId: string): { type?: string; key?: string } | undefined;
@@ -105,7 +105,7 @@ export function wrapAuthStorageWithApiKeyProviders(
return legacyCredential?.type === "oauth" ? legacyCredential : undefined; return legacyCredential?.type === "oauth" ? legacyCredential : undefined;
}; };
const migrateStoredAnthropicSubscriptionCredential = () => { const migrateStoredAnthropicSubscriptionCredential = async () => {
const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined; const existingSubscription = authStorage.get(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (existingSubscription?.type === "oauth") { if (existingSubscription?.type === "oauth") {
return existingSubscription; 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. 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. 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; 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. 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. 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") { 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 { } 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) { if (provider !== ANTHROPIC_STORAGE_PROVIDER_ID && provider !== ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID) {
mergedAuthStorage.logout(provider); await mergedAuthStorage.logout(provider);
return; return;
} }
mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID); await mergedAuthStorage.logout(ANTHROPIC_SUBSCRIPTION_STORAGE_PROVIDER_ID);
/* /*
FNXC:ProviderAuth 2026-06-29-23:59: FNXC:ProviderAuth 2026-06-29-23:59:
Logging out Anthropic subscription auth must also remove pre-split OAuth credentials still stored under `anthropic`. 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; const legacyAnthropicCredential = authStorage.get(ANTHROPIC_STORAGE_PROVIDER_ID) as StoredCredential | undefined;
if (legacyAnthropicCredential?.type === "oauth") { if (legacyAnthropicCredential?.type === "oauth") {
mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID); await mergedAuthStorage.logout(ANTHROPIC_STORAGE_PROVIDER_ID);
} }
}, },
getApiKeyProviders: () => { getApiKeyProviders: () => {
@@ -215,19 +215,19 @@ export function wrapAuthStorageWithApiKeyProviders(
a.name.localeCompare(b.name), a.name.localeCompare(b.name),
); );
}, },
setApiKey: (providerId, apiKey) => { setApiKey: async (providerId, apiKey) => {
const storageProviderId = toApiKeyStorageProviderId(providerId); const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { 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); const storageProviderId = toApiKeyStorageProviderId(providerId);
if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) { if (storageProviderId === ANTHROPIC_STORAGE_PROVIDER_ID) {
migrateStoredAnthropicSubscriptionCredential(); await migrateStoredAnthropicSubscriptionCredential();
} }
mergedAuthStorage.remove(storageProviderId); await mergedAuthStorage.remove(storageProviderId);
}, },
hasApiKey: (providerId) => { hasApiKey: (providerId) => {
const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId)); const credential = mergedAuthStorage.get(toApiKeyStorageProviderId(providerId));
@@ -303,7 +303,7 @@ export function mergeAuthStorageReads(
return selectCredential(providerId, readAuthStorages); return selectCredential(providerId, readAuthStorages);
}; };
const syncFallbackOauthCredentials = () => { const syncFallbackOauthCredentials = async (): Promise<void> => {
const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list())); const providerIds = new Set(readFallbackAuthStorages.flatMap((storage) => storage.list()));
for (const providerId of providerIds) { for (const providerId of providerIds) {
const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID const storageProviderId = providerId === ANTHROPIC_STORAGE_PROVIDER_ID
@@ -322,32 +322,32 @@ export function mergeAuthStorageReads(
FNXC:ProviderAuth 2026-06-29-23:48: 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`. 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, { return new Proxy(authStorage, {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === "logout") { if (prop === "logout") {
return (provider: string) => { return async (provider: string): Promise<void> => {
target.logout(provider); await target.logout(provider);
loggedOutProviders.add(provider); loggedOutProviders.add(provider);
}; };
} }
if (prop === "remove") { if (prop === "remove") {
return (provider: string) => { return async (provider: string): Promise<void> => {
target.remove(provider); await target.remove(provider);
loggedOutProviders.add(provider); loggedOutProviders.add(provider);
}; };
} }
if (prop === "set") { if (prop === "set") {
return (provider: string, credential: StoredCredential) => { return async (provider: string, credential: StoredCredential): Promise<void> => {
target.set(provider, credential); await target.set(provider, credential);
loggedOutProviders.delete(provider); loggedOutProviders.delete(provider);
}; };
} }
@@ -357,7 +357,7 @@ export function mergeAuthStorageReads(
for (const storage of readAuthStorages) { for (const storage of readAuthStorages) {
storage.reload(); storage.reload();
} }
syncFallbackOauthCredentials(); void syncFallbackOauthCredentials();
}; };
} }