FN-7646: prevent OAuth credential clobbering across concurrent Fusion processes
Fix API keys/OAuth credentials in ~/.fusion/agent/auth.json being clobbered when the desktop app and CLI-served web app run concurrently on one machine. - Reload primary auth storage from disk (primary.reload()) before persisting a refreshed OAuth credential, so a concurrent process's newer login/refresh for the same provider isn't overwritten by this process's stale in-flight refresh. - Re-check credential identity against the freshly reloaded disk state before writing the refreshed token back. - Add cross-process regression coverage exercising concurrent auth.json read-modify-write scenarios. - Add changeset documenting the fix and its dependency on the pi-coding-agent locked per-provider merge (>=0.80.x). Files changed: .changeset/fn-7646-auth-storage-coordination.md | 7 + .../src/__tests__/auth-storage-concurrency.test.ts | 234 +++++++++++++++++++++ packages/engine/src/auth-storage.ts | 27 +++ 3 files changed, 268 insertions(+) Fusion-Task-Id: FN-7646 Fusion-Task-Lineage: de39f08d-2d9f-46ff-b293-c603e3268ecf Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7646-auth-storage-coordination.md
Normal file
7
.changeset/fn-7646-auth-storage-coordination.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix provider API keys being wiped when the desktop and CLI apps share credentials on one machine.
|
||||
category: fix
|
||||
dev: createFusionAuthStorage() now reloads before persisting a refreshed OAuth credential so a concurrent Fusion process's newer login is not overwritten; adds cross-process regression coverage over ~/.fusion/agent/auth.json. Relies on the pi-coding-agent FileAuthStorageBackend locked per-provider merge (floor >=0.80.x).
|
||||
234
packages/engine/src/__tests__/auth-storage-concurrency.test.ts
Normal file
234
packages/engine/src/__tests__/auth-storage-concurrency.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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";
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-07-00:00:
|
||||
FN-7646: multiple independent Fusion processes on one machine (a CLI-served web app and a
|
||||
desktop app, or two CLI processes) each construct their own createFusionAuthStorage()
|
||||
instance over the SAME shared ~/.fusion/agent/auth.json. The vendored
|
||||
@earendil-works/pi-coding-agent FileAuthStorageBackend coordinates concurrent writers via
|
||||
proper-lockfile + a per-provider read-modify-merge (persistProviderChange /
|
||||
refreshOAuthTokenWithLock re-read the file under a lock and spread
|
||||
{...currentData, [provider]: credential}) — so a write from one process must never flush
|
||||
a stale full-snapshot view that clobbers another process's provider credentials. These
|
||||
tests reproduce the reported symptom (API keys saved in the web app vanish after the
|
||||
desktop app runs) as a real interleaved-writer scenario over a temp auth.json and assert
|
||||
survival both on disk and via a freshly constructed instance. They intentionally do NOT
|
||||
assert on raw token material — only presence/absence and provider ids/types.
|
||||
*/
|
||||
|
||||
function readAuthFile(homeDir: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(getFusionAuthPath(homeDir), "utf-8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("createFusionAuthStorage — concurrent cross-process coordination", () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalFetch = globalThis.fetch;
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), "fusion-engine-auth-concurrency-"));
|
||||
process.env.HOME = homeDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (homeDir) {
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
// 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", {
|
||||
type: "oauth",
|
||||
access: "desktop-access",
|
||||
refresh: "desktop-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
|
||||
expect(onDisk.openrouter).toEqual({ type: "api_key", key: "web-openrouter-key" });
|
||||
expect((onDisk["anthropic-subscription"] as { type: string }).type).toBe("oauth");
|
||||
|
||||
const instanceC = createFusionAuthStorage();
|
||||
expect(await instanceC.getApiKey("openai")).toBe("web-openai-key");
|
||||
expect(await instanceC.getApiKey("openrouter")).toBe("web-openrouter-key");
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
// 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" });
|
||||
|
||||
// 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" });
|
||||
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
|
||||
expect(onDisk.openrouter).toEqual({ type: "api_key", key: "web-openrouter-key" });
|
||||
expect(onDisk.groq).toEqual({ type: "api_key", key: "desktop-groq-key" });
|
||||
|
||||
const instanceC = createFusionAuthStorage();
|
||||
expect(await instanceC.getApiKey("openai")).toBe("web-openai-key");
|
||||
expect(await instanceC.getApiKey("openrouter")).toBe("web-openrouter-key");
|
||||
expect(await instanceC.getApiKey("groq")).toBe("desktop-groq-key");
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
const instanceB = createFusionAuthStorage();
|
||||
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");
|
||||
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
|
||||
expect(onDisk.openrouter).toEqual({ type: "api_key", key: "web-openrouter-key" });
|
||||
expect(onDisk.groq).toEqual({ type: "api_key", key: "web-groq-key" });
|
||||
expect(onDisk.anthropic).toBeUndefined();
|
||||
|
||||
const instanceC = createFusionAuthStorage();
|
||||
expect(await instanceC.getApiKey("openai")).toBe("web-openai-key");
|
||||
expect(await instanceC.getApiKey("openrouter")).toBe("web-openrouter-key");
|
||||
expect(await instanceC.getApiKey("groq")).toBe("web-groq-key");
|
||||
});
|
||||
|
||||
it("survives instance B's remove() call for an unrelated provider (empty-file baseline)", async () => {
|
||||
// Start from an explicit empty auth.json (empty-file data-state surface).
|
||||
mkdirSync(join(homeDir, ".fusion", "agent"), { recursive: true });
|
||||
writeFileSync(getFusionAuthPath(homeDir), "{}");
|
||||
|
||||
const instanceA = createFusionAuthStorage();
|
||||
instanceA.set("openai", { type: "api_key", key: "web-openai-key" });
|
||||
|
||||
const instanceB = createFusionAuthStorage();
|
||||
instanceA.set("mistral", { type: "api_key", key: "web-mistral-key" });
|
||||
|
||||
instanceB.set("anthropic-subscription", {
|
||||
type: "oauth",
|
||||
access: "desktop-access",
|
||||
refresh: "desktop-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
instanceB.remove("anthropic-subscription");
|
||||
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
|
||||
expect(onDisk.mistral).toEqual({ type: "api_key", key: "web-mistral-key" });
|
||||
expect(onDisk["anthropic-subscription"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("survives a supplemental-sync-triggered write from instance B", async () => {
|
||||
// Supplemental credential source (.pi legacy auth.json, OAuth) that
|
||||
// syncSupplementalOauthCredentials() hydrates into the primary store on
|
||||
// construction/reload — only OAuth candidates are auto-hydrated
|
||||
// (shouldHydrateStoredCredential requires candidate.type === "oauth"; a
|
||||
// supplemental api_key is read as a fallback but is never written to primary).
|
||||
const legacyAgentDir = join(homeDir, ".pi", "agent");
|
||||
mkdirSync(legacyAgentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(legacyAgentDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "legacy-codex-access",
|
||||
refresh: "legacy-codex-refresh",
|
||||
expires: Date.now() + 3_600_000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const instanceA = createFusionAuthStorage();
|
||||
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
|
||||
// the "supplemental sync" write path that must not clobber A's provider.
|
||||
const instanceB = createFusionAuthStorage();
|
||||
void instanceB;
|
||||
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
expect(onDisk.openai).toEqual({ type: "api_key", key: "web-openai-key" });
|
||||
expect((onDisk["openai-codex"] as { type?: string } | undefined)?.type).toBe("oauth");
|
||||
});
|
||||
|
||||
it("[same-provider] instance B's older in-flight OAuth refresh does not overwrite instance A's newer login for the same provider", async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// 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", {
|
||||
type: "oauth",
|
||||
access: "old-access",
|
||||
refresh: "old-refresh",
|
||||
expires: now + 1_000, // within the refresh buffer window
|
||||
});
|
||||
|
||||
// B constructs and snapshots the OLD credential in memory.
|
||||
const instanceB = createFusionAuthStorage();
|
||||
|
||||
// The user re-logs in via A with a fresh, long-lived credential AFTER B snapshotted.
|
||||
instanceA.set("anthropic", {
|
||||
type: "oauth",
|
||||
access: "new-access-from-relogin",
|
||||
refresh: "new-refresh-from-relogin",
|
||||
expires: now + 3_600_000,
|
||||
});
|
||||
|
||||
// B's in-flight refresh (still keyed off its stale "old-*" snapshot) resolves.
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: "refreshed-from-stale-old-token",
|
||||
refresh_token: "refreshed-from-stale-old-refresh",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const refreshedKey = await instanceB.getApiKey("anthropic");
|
||||
|
||||
// B must not have flushed a refresh derived from the stale credential over A's
|
||||
// newer login. Either B returns A's newer key directly, or (at minimum) the
|
||||
// newer credential must still be what's on disk afterward.
|
||||
const onDisk = readAuthFile(homeDir);
|
||||
const persisted = onDisk.anthropic as { access?: string };
|
||||
expect(persisted.access).toBe("new-access-from-relogin");
|
||||
expect(refreshedKey).not.toBe("refreshed-from-stale-old-token");
|
||||
|
||||
const instanceC = createFusionAuthStorage();
|
||||
expect(await instanceC.getApiKey("anthropic")).not.toBe("refreshed-from-stale-old-token");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,21 @@ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
|
||||
|
||||
type StoredCredential = StoredAuthCredential;
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-07-00:00:
|
||||
FN-7646: the cross-process ~/.fusion/agent/auth.json coordination invariant (concurrent
|
||||
writers merge per-provider instead of clobbering each other's credentials) depends on the
|
||||
vendored @earendil-works/pi-coding-agent AuthStorage backend using proper-lockfile locking
|
||||
plus a per-provider read-modify-merge (FileAuthStorageBackend.persistProviderChange /
|
||||
refreshOAuthTokenWithLock re-read the file under a lock and spread
|
||||
{...currentData, [provider]: credential} rather than flushing a whole-file in-memory
|
||||
snapshot). packages/engine/package.json already floors this at
|
||||
"@earendil-works/pi-coding-agent": "^0.80.3" (caret bounds it to >=0.80.3 <0.81.0, which is
|
||||
where this locked per-provider merge landed) — do not downgrade below 0.80.x, and re-verify
|
||||
this comment against dist/core/auth-storage.js if the range is ever widened. See
|
||||
packages/engine/src/__tests__/auth-storage-concurrency.test.ts for the regression coverage.
|
||||
*/
|
||||
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-05-00:00:
|
||||
FN-7574: a 60s reactive refresh buffer meant a healthy Anthropic subscription token was
|
||||
@@ -556,7 +571,19 @@ export function createFusionAuthStorage(): AuthStorage {
|
||||
FNXC:ClaudeOAuth 2026-06-13-22:46:
|
||||
A manual re-login or replacement credential must win over an older in-flight refresh response.
|
||||
Re-check the credential identity before persisting so a delayed refresh cannot restore stale OAuth material after the user already fixed auth.
|
||||
|
||||
FNXC:ProviderAuth 2026-07-07-00:00:
|
||||
FN-7646: ~/.fusion/agent/auth.json is shared across independent Fusion processes on one
|
||||
machine (e.g. a CLI-served web app and the desktop app). The FileAuthStorageBackend already
|
||||
coordinates concurrent writers with a lock plus a per-provider read-modify-merge, so it never
|
||||
clobbers OTHER providers — but this process's own in-flight refresh must not overwrite a
|
||||
NEWER credential another process wrote for the SAME provider while this refresh was pending.
|
||||
Re-read from disk (primary.reload()) before comparing identity so `latestCredential` reflects
|
||||
what is actually on disk right now, not this process's possibly-stale in-memory snapshot from
|
||||
before the refresh started. Without this reload, a concurrent process's newer login/refresh for
|
||||
this exact provider could be silently overwritten by our older refreshed token.
|
||||
*/
|
||||
primary.reload();
|
||||
const latestCredential = selectStoredCredential(storageProvider);
|
||||
if (!isSameOAuthCredentialIdentity(latestCredential, credential)) {
|
||||
return resolveStoredCredentialApiKey(storageProvider, latestCredential);
|
||||
|
||||
Reference in New Issue
Block a user