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:
7
.changeset/fn-8205-auth-lock-retry.md
Normal file
7
.changeset/fn-8205-auth-lock-retry.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -26,7 +26,7 @@ function makeAuthStorage(credentials: Record<string, { type: string; key?: strin
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("wrapAuthStorageWithApiKeyProviders", () => {
|
||||
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");
|
||||
|
||||
@@ -207,7 +207,7 @@ export async function runOnboard(options: OnboardOptions = {}): Promise<void> {
|
||||
}
|
||||
|
||||
const apiKey = await prompts.prompt("Enter API key");
|
||||
providerAuth.setApiKey(selectedProvider, apiKey);
|
||||
await providerAuth.setApiKey(selectedProvider, apiKey);
|
||||
console.log(`✓ Stored API key for ${selectedProvider}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -249,13 +249,13 @@ export interface AuthStorageLike {
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<void>;
|
||||
logout(provider: string): void;
|
||||
logout(provider: string): Promise<void>;
|
||||
/** 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<void>;
|
||||
/** 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. */
|
||||
hasApiKey?(providerId: string): boolean;
|
||||
/** Get the configured API key for usage providers. */
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 () => {
|
||||
// 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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
remove(provider: string): Promise<void>;
|
||||
logout(provider: string): Promise<void>;
|
||||
getApiKey(provider: string): Promise<string | undefined>;
|
||||
getOAuthProviders(): Array<{ id: string; name: string }>;
|
||||
login(provider: string, callbacks: unknown): Promise<void>;
|
||||
@@ -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<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 {
|
||||
private data: Record<string, StoredCredential> = {};
|
||||
private modelRuntime: ModelRuntime | undefined;
|
||||
@@ -57,20 +93,22 @@ class FusionFileAuthStorage implements FusionAuthStorage {
|
||||
}
|
||||
}
|
||||
|
||||
private withLock<T>(fn: (current: Record<string, StoredCredential>) => 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<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
|
||||
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<void> {
|
||||
await this.withLock(async (current) => { current[provider] = credential; });
|
||||
}
|
||||
remove(provider: string): void {
|
||||
this.withLock((current) => { delete current[provider]; });
|
||||
async remove(provider: string): Promise<void> {
|
||||
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> {
|
||||
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<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
|
||||
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<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 oauthRefreshCooldownUntil = new Map<string, number>();
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
|
||||
@@ -35,10 +35,10 @@ export interface DashboardAuthStorage {
|
||||
getOAuthProviders(): Array<{ id: string; name: string }>;
|
||||
hasAuth(provider: string): boolean;
|
||||
login(providerId: string, callbacks: LoginCallbacks): Promise<void>;
|
||||
logout(provider: string): void;
|
||||
logout(provider: string): Promise<void>;
|
||||
getApiKeyProviders(): Array<{ id: string; name: string }>;
|
||||
setApiKey(providerId: string, apiKey: string): void;
|
||||
clearApiKey(providerId: string): void;
|
||||
setApiKey(providerId: string, apiKey: string): Promise<void>;
|
||||
clearApiKey(providerId: string): Promise<void>;
|
||||
hasApiKey(providerId: string): boolean;
|
||||
getApiKey(providerId: string): Promise<string | undefined>;
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
await target.logout(provider);
|
||||
loggedOutProviders.add(provider);
|
||||
};
|
||||
}
|
||||
|
||||
if (prop === "remove") {
|
||||
return (provider: string) => {
|
||||
target.remove(provider);
|
||||
return async (provider: string): Promise<void> => {
|
||||
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<void> => {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user