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:
gsxdsm
2026-07-07 14:25:26 -07:00
parent 4e8c621e9c
commit 009ce26fd0
3 changed files with 268 additions and 0 deletions

View 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).

View 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");
});
});

View File

@@ -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);