FN-8651: add named provider credential instances

Add provider-instance identity and auth.json storage for multiple credentials per provider.

- Export provider-instance key parsing, validation, and reserved metadata helpers.
- Resolve, list, mutate, and select named credential instances atomically in auth storage.
- Preserve legacy bare-key hydration and document the auth.json instance contract.
- Cover provider instance parsing, storage behavior, and concurrent writes.

Files changed:
 .changeset/fn-8651-provider-instances.md           |   7 +
 docs/secrets.md                                    |   8 +
 .../src/__tests__/oauth-credential-interop.test.ts |  16 ++
 packages/core/src/index.ts                         |  16 ++
 packages/core/src/oauth-credential-interop.ts      |  15 +-
 packages/core/src/provider-instance.test.ts        |  24 +++
 packages/core/src/provider-instance.ts             |  65 +++++++
 .../src/__tests__/auth-storage-concurrency.test.ts |  13 ++
 .../src/__tests__/auth-storage-instances.test.ts   |  75 ++++++++
 packages/engine/src/auth-storage.ts                | 214 +++++++++++++--------
 10 files changed, 367 insertions(+), 86 deletions(-)

Fusion-Task-Id: FN-8651

Fusion-Task-Lineage: cfabe496-60f9-4166-a09f-87ea2028cfd9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 21:56:30 -07:00
parent e080bca464
commit 182e3fdbe8
10 changed files with 367 additions and 86 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Support multiple named credential instances per AI provider.
category: feature
dev: Adds core provider-instance exports and provider[instance] auth.json storage keys.

View File

@@ -204,6 +204,14 @@ Track follow-up: **FN-5031** (missing `packages/core/src/__tests__/secrets-env.t
**Plaintext prohibition:** audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use `assertNoSecretPlaintext(...)` as the canonical enforcement helper before emitting secret audit events. **Plaintext prohibition:** audit payload metadata must never include plaintext, decrypted values, ciphertext, or nonce fields. Use `assertNoSecretPlaintext(...)` as the canonical enforcement helper before emitting secret audit events.
## Provider credential instances (`auth.json`)
Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare key such as `"openrouter"` is the default instance; a named key is `"openrouter[work]"`. Provider and instance ids are non-empty, no-whitespace, no-bracket strings up to 64 characters. Existing bare entries remain readable and are not rewritten merely by reading them.
`__fusionDefaultInstances` is reserved metadata, never a credential. Its per-provider pointer wins only when it names an existing valid credential; resolution otherwise falls back to the bare key, then the lexicographically first named instance, then no instance (`getDefaultInstance` returns `undefined`). The metadata is untrusted: malformed records or stale entries are ignored without a read-time repair. Deleting a pointed-to instance removes that pointer in the same locked write.
Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys.
## Operational Notes ## Operational Notes
- Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority. - Backups: preserve PostgreSQL project/central schemas and the master-key material/provider source used by the deployment. Retain legacy SQLite backups only as controlled migration/recovery inputs; they are not runtime authority.

View File

@@ -143,6 +143,22 @@ describe("oauth credential interop", () => {
]); ]);
}); });
it("keeps only bare provider credentials from Fusion multi-instance auth files", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));
try {
const authPath = join(tempDir, "auth.json");
writeFileSync(authPath, JSON.stringify({
provider: { type: "api_key", key: "bare" },
"provider[work]": { type: "api_key", key: "named" },
"provider[": { type: "api_key", key: "malformed" },
__fusionDefaultInstances: { provider: "work" },
}));
expect(readStoredCredentialsFromAuthFile(authPath)).toEqual({ provider: { type: "api_key", key: "bare" } });
writeFileSync(authPath, JSON.stringify({ "provider[work]": { type: "api_key", key: "named" } }));
expect(readStoredCredentialsFromAuthFile(authPath)).toEqual({});
} finally { rmSync(tempDir, { recursive: true, force: true }); }
});
it("gracefully ignores malformed auth files", () => { it("gracefully ignores malformed auth files", () => {
const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-")); const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-"));

View File

@@ -2389,8 +2389,24 @@ export {
getCodexCliAuthPath, getCodexCliAuthPath,
readStoredCredentialsFromAuthFile, readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential, shouldHydrateStoredCredential,
isStoredAuthCredential,
} from "./oauth-credential-interop.js"; } from "./oauth-credential-interop.js";
export type { StoredAuthCredential } from "./oauth-credential-interop.js"; export type { StoredAuthCredential } from "./oauth-credential-interop.js";
export {
ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
DEFAULT_PROVIDER_INSTANCE_ID,
PROVIDER_INSTANCE_ID_MAX_LENGTH,
RESERVED_AUTH_STORAGE_KEYS,
assertValidProviderId,
assertValidProviderInstanceId,
formatProviderInstanceKey,
isDefaultProviderInstance,
isReservedAuthStorageKey,
isValidProviderId,
isValidProviderInstanceId,
parseProviderInstanceKey,
} from "./provider-instance.js";
export type { ProviderInstanceRef } from "./provider-instance.js";
// ── Error helpers ───────────────────────────────────────── // ── Error helpers ─────────────────────────────────────────
export { getErrorMessage } from "./error-message.js"; export { getErrorMessage } from "./error-message.js";

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { isDefaultProviderInstance, parseProviderInstanceKey } from "./provider-instance.js";
export type StoredAuthCredential = { export type StoredAuthCredential = {
type?: string; type?: string;
@@ -82,7 +83,7 @@ function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefine
return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS; return parsed + CODEX_REFRESH_FALLBACK_WINDOW_MS;
} }
function isStoredAuthCredential(value: unknown): value is StoredAuthCredential { export function isStoredAuthCredential(value: unknown): value is StoredAuthCredential {
if (!value || typeof value !== "object" || Array.isArray(value)) { if (!value || typeof value !== "object" || Array.isArray(value)) {
return false; return false;
} }
@@ -275,11 +276,17 @@ export function readStoredCredentialsFromAuthFile(authPath: string): Record<stri
} }
const credentials: Record<string, StoredAuthCredential> = {}; const credentials: Record<string, StoredAuthCredential> = {};
for (const [providerId, value] of Object.entries(parsed as Record<string, unknown>)) { for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (!isStoredAuthCredential(value)) { const ref = parseProviderInstanceKey(key);
if (!ref || !isDefaultProviderInstance(ref.instanceId) || !isStoredAuthCredential(value)) {
continue; continue;
} }
credentials[providerId] = value; /*
FNXC:ProviderAuth 2026-08-01-04:36:
External Claude/Codex hydration accepts only bare provider keys. A named instance is
Fusion-internal selection state and must not masquerade as a provider id downstream.
*/
credentials[ref.providerId] = value;
} }
return credentials; return credentials;
} catch { } catch {

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_PROVIDER_INSTANCE_ID,
PROVIDER_INSTANCE_ID_MAX_LENGTH,
formatProviderInstanceKey,
isReservedAuthStorageKey,
parseProviderInstanceKey,
} from "./provider-instance.js";
describe("provider instance keys", () => {
it("round trips bare defaults and named instances", () => {
expect(parseProviderInstanceKey(formatProviderInstanceKey({ providerId: "anthropic", instanceId: DEFAULT_PROVIDER_INSTANCE_ID }))).toEqual({ providerId: "anthropic", instanceId: DEFAULT_PROVIDER_INSTANCE_ID });
expect(parseProviderInstanceKey(formatProviderInstanceKey({ providerId: "anthropic", instanceId: "work" }))).toEqual({ providerId: "anthropic", instanceId: "work" });
});
it("rejects non-invertible provider and instance names", () => {
for (const value of ["", " ", "a b", "a[b", "a]b", "a".repeat(PROVIDER_INSTANCE_ID_MAX_LENGTH + 1)]) {
expect(() => formatProviderInstanceKey({ providerId: value, instanceId: "work" })).toThrow();
expect(() => formatProviderInstanceKey({ providerId: "provider", instanceId: value })).toThrow();
}
for (const key of ["p[", "p]", "p[]", "p[a[b]", "p[default]", ""]) expect(parseProviderInstanceKey(key)).toBeUndefined();
expect(isReservedAuthStorageKey("__fusionDefaultInstances")).toBe(true);
});
});

View File

@@ -0,0 +1,65 @@
export const DEFAULT_PROVIDER_INSTANCE_ID = "default";
export const PROVIDER_INSTANCE_ID_MAX_LENGTH = 64;
export const RESERVED_AUTH_STORAGE_KEYS = ["__fusionDefaultInstances"] as const;
export const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
export type ProviderInstanceRef = { providerId: string; instanceId: string };
export function isReservedAuthStorageKey(key: string): boolean {
return (RESERVED_AUTH_STORAGE_KEYS as readonly string[]).includes(key);
}
export function isDefaultProviderInstance(instanceId: string): boolean {
return instanceId === DEFAULT_PROVIDER_INSTANCE_ID;
}
export function isValidProviderInstanceId(instanceId: unknown): instanceId is string {
return typeof instanceId === "string"
&& instanceId.length > 0
&& instanceId.length <= PROVIDER_INSTANCE_ID_MAX_LENGTH
&& !/\s/.test(instanceId)
&& !instanceId.includes("[")
&& !instanceId.includes("]");
}
export function assertValidProviderInstanceId(instanceId: unknown): asserts instanceId is string {
if (!isValidProviderInstanceId(instanceId)) {
throw new Error(`Invalid provider instance id: ${String(instanceId)}`);
}
}
export function isValidProviderId(providerId: unknown): providerId is string {
return isValidProviderInstanceId(providerId) && !isReservedAuthStorageKey(providerId);
}
export function assertValidProviderId(providerId: unknown): asserts providerId is string {
if (!isValidProviderId(providerId)) {
throw new Error(`Invalid or reserved provider id: ${String(providerId)}`);
}
}
/*
FNXC:ProviderAuth 2026-08-01-04:36:
FN-8651 keeps auth.json compatible by spelling the default instance as a bare provider id and named instances as provider[instance]. Validation makes format and parse exact inverses so legacy APIs cannot write ambiguous raw keys. The reserved defaults record is never a provider because deleting or overwriting metadata would strand credentials.
*/
export function formatProviderInstanceKey(ref: ProviderInstanceRef): string {
assertValidProviderId(ref.providerId);
assertValidProviderInstanceId(ref.instanceId);
return isDefaultProviderInstance(ref.instanceId) ? ref.providerId : `${ref.providerId}[${ref.instanceId}]`;
}
export function parseProviderInstanceKey(key: string): ProviderInstanceRef | undefined {
if (typeof key !== "string" || key.length === 0) return undefined;
const open = key.indexOf("[");
const close = key.indexOf("]");
const isBare = open === -1 && close === -1;
const isNamed = open > 0 && close === key.length - 1 && key.indexOf("[", open + 1) === -1 && key.indexOf("]", close + 1) === -1;
if (!isBare && !isNamed) return undefined;
const providerId = isBare ? key : key.slice(0, open);
const instanceId = isBare ? DEFAULT_PROVIDER_INSTANCE_ID : key.slice(open + 1, -1);
return isValidProviderId(providerId)
&& isValidProviderInstanceId(instanceId)
&& (!isNamed || !isDefaultProviderInstance(instanceId))
? { providerId, instanceId }
: undefined;
}

View File

@@ -280,6 +280,19 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", ()
expect(await instanceC.getApiKey("anthropic")).not.toBe("refreshed-from-stale-old-token"); expect(await instanceC.getApiKey("anthropic")).not.toBe("refreshed-from-stale-old-token");
}); });
it("merges concurrent named and bare instance writes for one provider", async () => {
const instanceA = createFusionAuthStorage();
const instanceB = createFusionAuthStorage();
await Promise.all([
instanceA.setInstance({ providerId: "openrouter", instanceId: "work" }, { type: "api_key", key: "work-key" }),
instanceB.setInstance({ providerId: "openrouter", instanceId: "backup" }, { type: "api_key", key: "backup-key" }),
]);
const instanceC = createFusionAuthStorage();
expect(instanceC.getInstance({ providerId: "openrouter", instanceId: "work" })).toMatchObject({ key: "work-key" });
expect(instanceC.getInstance({ providerId: "openrouter", instanceId: "backup" })).toMatchObject({ key: "backup-key" });
expect((readAuthFile(homeDir)["openrouter[work]"])).toBeDefined();
});
it("single-flights a rotating Anthropic refresh token across auth storage instances", async () => { it("single-flights a rotating Anthropic refresh token across auth storage instances", async () => {
const now = Date.now(); const now = Date.now();
const instanceA = createFusionAuthStorage(); const instanceA = createFusionAuthStorage();

View File

@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdirSync, readFileSync, statSync, 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";
const credential = (key: string) => ({ type: "api_key", key });
describe("instance-aware Fusion auth storage", () => {
const originalHome = process.env.HOME;
let home: string;
beforeEach(async () => { home = await mkdtemp(join(tmpdir(), "fusion-auth-instances-")); process.env.HOME = home; });
afterEach(async () => { await rm(home, { recursive: true, force: true }); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; });
const authPath = () => getFusionAuthPath(home);
const seed = (data: Record<string, unknown>) => { mkdirSync(join(home, ".fusion", "agent"), { recursive: true }); writeFileSync(authPath(), JSON.stringify(data)); };
it("stores coexisting instances and resolves a pointer before a bare legacy key", async () => {
const store = createFusionAuthStorage();
await store.setInstance({ providerId: "p", instanceId: "work" }, credential("work"));
await store.setInstance({ providerId: "p", instanceId: "backup" }, credential("backup"));
await store.set("p", credential("updated-work"));
expect(JSON.parse(readFileSync(authPath(), "utf8"))["p[backup]"]).toEqual(credential("updated-work"));
await store.setInstance({ providerId: "p", instanceId: "default" }, credential("bare"));
await store.setDefaultInstance({ providerId: "p", instanceId: "work" });
expect(store.get("p")).toEqual(credential("work"));
expect(await store.getApiKey("p")).toBe("work");
expect(store.getAll().p).toEqual(credential("work"));
expect(store.listInstances("p")[0]).toEqual({ providerId: "p", instanceId: "work" });
await store.setDefaultInstance({ providerId: "p", instanceId: "default" });
expect(store.get("p")).toEqual(credential("bare"));
});
it("keeps legacy bare credentials readable without a read rewrite", () => {
seed({ provider: credential("legacy") });
const before = readFileSync(authPath(), "utf8"); const mtime = statSync(authPath()).mtimeMs;
const store = createFusionAuthStorage();
expect(store.getDefaultInstance("provider")).toEqual({ providerId: "provider", instanceId: "default" });
expect(store.get("provider")).toEqual(credential("legacy"));
expect(readFileSync(authPath(), "utf8")).toBe(before); expect(statSync(authPath()).mtimeMs).toBe(mtime);
});
it("uses legacy bare creation but non-creating calls do not write absent providers", async () => {
const store = createFusionAuthStorage();
await store.set("brand-new", credential("new"));
expect(JSON.parse(readFileSync(authPath(), "utf8"))).toEqual({ "brand-new": credential("new") });
const before = readFileSync(authPath(), "utf8"); const mtime = statSync(authPath()).mtimeMs;
let invoked = false;
await store.remove("absent"); await store.logout("absent"); await store.removeInstance({ providerId: "absent", instanceId: "x" });
await store.modify("absent", async () => { invoked = true; return credential("bad"); });
expect(invoked).toBe(false); expect(readFileSync(authPath(), "utf8")).toBe(before); expect(statSync(authPath()).mtimeMs).toBe(mtime);
});
it("rejects malformed and reserved mutator keys without touching defaults metadata", async () => {
seed({ __fusionDefaultInstances: { p: "work" } }); const store = createFusionAuthStorage(); const before = readFileSync(authPath(), "utf8");
for (const bad of ["p[", "p]", "p[]", "p[a[b]", "", "__fusionDefaultInstances"]) {
await expect(store.set(bad, credential("bad"))).rejects.toThrow();
await expect(store.remove(bad)).rejects.toThrow();
await expect(store.logout(bad)).rejects.toThrow();
await expect(store.modify(bad, async () => credential("bad"))).rejects.toThrow();
expect(store.get(bad)).toBeUndefined(); expect(store.has(bad)).toBe(false);
}
expect(readFileSync(authPath(), "utf8")).toBe(before);
});
it("filters metadata and malformed credential values and falls back after default deletion", async () => {
seed({ p: credential("bare"), "p[work]": credential("work"), "p[bad]": "not-a-credential", __fusionDefaultInstances: { p: "work" } });
const store = createFusionAuthStorage();
expect(store.list()).toEqual(["p"]); expect(store.getAll()).toEqual({ p: credential("work") });
await store.removeInstance({ providerId: "p", instanceId: "work" });
expect(store.get("p")).toEqual(credential("bare"));
expect(JSON.parse(readFileSync(authPath(), "utf8")).__fusionDefaultInstances).toEqual({});
await expect(store.setDefaultInstance({ providerId: "p", instanceId: "missing" })).rejects.toThrow();
});
});

View File

@@ -9,6 +9,15 @@ import {
readStoredCredentialsFromAuthFile, readStoredCredentialsFromAuthFile,
shouldHydrateStoredCredential, shouldHydrateStoredCredential,
type StoredAuthCredential, type StoredAuthCredential,
type ProviderInstanceRef,
ANTHROPIC_SUBSCRIPTION_PROVIDER_ID,
DEFAULT_PROVIDER_INSTANCE_ID,
formatProviderInstanceKey,
isDefaultProviderInstance,
isReservedAuthStorageKey,
isStoredAuthCredential,
isValidProviderId,
parseProviderInstanceKey,
} from "@fusion/core"; } from "@fusion/core";
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; import type { AuthInteraction, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
@@ -22,6 +31,12 @@ export interface FusionAuthStorage {
list(): string[]; list(): string[];
has(provider: string): boolean; has(provider: string): boolean;
hasAuth(provider: string): boolean; hasAuth(provider: string): boolean;
listInstances(providerId: string): ProviderInstanceRef[];
getInstance(ref: ProviderInstanceRef): StoredCredential | undefined;
setInstance(ref: ProviderInstanceRef, credential: StoredCredential): Promise<void>;
removeInstance(ref: ProviderInstanceRef): Promise<void>;
getDefaultInstance(providerId: string): ProviderInstanceRef | undefined;
setDefaultInstance(ref: ProviderInstanceRef): Promise<void>;
set(provider: string, credential: StoredCredential): Promise<void>; set(provider: string, credential: StoredCredential): Promise<void>;
remove(provider: string): Promise<void>; remove(provider: string): Promise<void>;
logout(provider: string): Promise<void>; logout(provider: string): Promise<void>;
@@ -85,109 +100,140 @@ async function withOAuthRefreshLock<T>(
} }
} }
type AuthFileData = Record<string, unknown>;
type DefaultInstanceMap = Record<string, string>;
function readDefaultInstanceMap(data: AuthFileData): DefaultInstanceMap {
const candidate = data.__fusionDefaultInstances;
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return {};
return candidate as DefaultInstanceMap;
}
/*
FNXC:ProviderAuth 2026-08-01-04:36:
FN-8651 treats auth.json as an untrusted metadata-capable record, not a credential map.
The resolver is the sole default precedence authority: valid pointer, bare legacy key,
sorted named key, then undefined. A pointer deliberately wins over a bare key so changing
the default is observable through every legacy string API; absence returns no fabricated ref.
*/
class FusionFileAuthStorage implements FusionAuthStorage { class FusionFileAuthStorage implements FusionAuthStorage {
private data: Record<string, StoredCredential> = {}; private data: AuthFileData = {};
private modelRuntime: ModelRuntime | undefined; private modelRuntime: ModelRuntime | undefined;
constructor(private readonly authPath: string) { constructor(private readonly authPath: string) { this.reload(); }
this.reload();
}
private ensureFile(): void { private ensureFile(): void {
const parent = dirname(this.authPath); const parent = dirname(this.authPath);
if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 }); if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 });
if (!existsSync(this.authPath)) { if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 }); chmodSync(this.authPath, 0o600); }
writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 });
chmodSync(this.authPath, 0o600);
}
} }
private readCurrent(): AuthFileData {
private readCurrent(): Record<string, StoredCredential> {
try { try {
return JSON.parse(readFileSync(this.authPath, "utf-8")) as Record<string, StoredCredential>; const parsed: unknown = JSON.parse(readFileSync(this.authPath, "utf-8"));
} catch { return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as AuthFileData : {};
return {}; } catch { return {}; }
}
} }
private async withLock<T>(fn: (current: AuthFileData) => Promise<{ result: T; changed: boolean }>): Promise<T> {
private async withLock<T>(fn: (current: Record<string, StoredCredential>) => Promise<T>): Promise<T> {
return enqueueAuthWrite(this.authPath, async () => { return enqueueAuthWrite(this.authPath, async () => {
this.ensureFile(); this.ensureFile(); const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS);
try { try {
// Always merge the on-disk state observed after acquiring the lock, never this.data. const current = this.readCurrent(); const { result, changed } = await fn(current);
const current = this.readCurrent(); if (changed) { writeFileSync(this.authPath, JSON.stringify(current, null, 2), { encoding: "utf-8", mode: 0o600 }); chmodSync(this.authPath, 0o600); this.data = current; }
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; return result;
} finally { } finally { await release(); }
await release();
}
}); });
} }
reload(): void { this.ensureFile(); this.data = this.readCurrent(); }
reload(): void { private parseReadKey(key: string): ProviderInstanceRef | undefined { return parseProviderInstanceKey(key); }
this.ensureFile(); private assertRef(ref: ProviderInstanceRef): ProviderInstanceRef {
this.data = this.readCurrent(); // format validates both ids and rejects reserved provider names before any mutator locks.
formatProviderInstanceKey(ref); return ref;
} }
private resolveDefaultInstance(providerId: string, data: AuthFileData): ProviderInstanceRef | undefined {
get(provider: string): StoredCredential | undefined { return this.data[provider]; } if (!isValidProviderId(providerId) || isReservedAuthStorageKey(providerId)) return undefined;
getAll(): Record<string, StoredCredential> { return { ...this.data }; } const pointer = readDefaultInstanceMap(data)[providerId];
list(): string[] { return Object.keys(this.data); } if (typeof pointer === "string") {
has(provider: string): boolean { return Boolean(this.data[provider]); } const ref = { providerId, instanceId: pointer };
try { if (isStoredAuthCredential(data[formatProviderInstanceKey(ref)])) return ref; } catch { /* invalid untrusted pointer falls through */ }
}
const bare = { providerId, instanceId: DEFAULT_PROVIDER_INSTANCE_ID };
if (isStoredAuthCredential(data[providerId])) return bare;
const named = Object.keys(data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && ref!.providerId === providerId && !isDefaultProviderInstance(ref!.instanceId) && isStoredAuthCredential(data[formatProviderInstanceKey(ref!)]));
return named.sort((a, b) => a.instanceId.localeCompare(b.instanceId))[0];
}
private resolveReadTarget(providerKey: string, data: AuthFileData): ProviderInstanceRef | undefined {
const ref = this.parseReadKey(providerKey); if (!ref) return undefined;
return isDefaultProviderInstance(ref.instanceId) ? this.resolveDefaultInstance(ref.providerId, data) : ref;
}
private resolveWriteTarget(providerKey: string, data: AuthFileData, creating: boolean): ProviderInstanceRef | undefined {
const ref = this.assertRefFromKey(providerKey);
if (!isDefaultProviderInstance(ref.instanceId)) return ref;
return this.resolveDefaultInstance(ref.providerId, data) ?? (creating ? ref : undefined);
}
private assertRefFromKey(key: string): ProviderInstanceRef {
const ref = parseProviderInstanceKey(key); if (!ref) throw new Error(`Invalid provider key: ${key}`); return this.assertRef(ref);
}
private credential(ref: ProviderInstanceRef | undefined, data = this.data): StoredCredential | undefined {
if (!ref) return undefined;
const candidate = data[formatProviderInstanceKey(ref)];
return isStoredAuthCredential(candidate) ? candidate : undefined;
}
get(provider: string): StoredCredential | undefined { return this.credential(this.resolveReadTarget(provider, this.data)); }
getInstance(ref: ProviderInstanceRef): StoredCredential | undefined { try { return this.credential(this.assertRef(ref)); } catch { return undefined; } }
getDefaultInstance(providerId: string): ProviderInstanceRef | undefined { return this.resolveDefaultInstance(providerId, this.data); }
listInstances(providerId: string): ProviderInstanceRef[] {
if (!isValidProviderId(providerId) || isReservedAuthStorageKey(providerId)) return [];
const refs = Object.keys(this.data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && ref!.providerId === providerId && Boolean(this.credential(ref!, this.data)));
const defaultRef = this.resolveDefaultInstance(providerId, this.data);
const defaultKey = defaultRef && formatProviderInstanceKey(defaultRef);
return refs.sort((a, b) => {
const aIsDefault = formatProviderInstanceKey(a) === defaultKey;
const bIsDefault = formatProviderInstanceKey(b) === defaultKey;
if (aIsDefault !== bIsDefault) return aIsDefault ? -1 : 1;
return a.instanceId.localeCompare(b.instanceId);
});
}
getAll(): Record<string, StoredCredential> { const result: Record<string, StoredCredential> = {}; for (const provider of this.list()) { const credential = this.get(provider); if (credential) result[provider] = credential; } return result; }
list(): string[] { return [...new Set(Object.keys(this.data).map(parseProviderInstanceKey).filter((ref): ref is ProviderInstanceRef => Boolean(ref) && Boolean(this.credential(ref!, this.data))).map((ref) => ref.providerId))].sort(); }
has(provider: string): boolean { return Boolean(this.get(provider)); }
hasAuth(provider: string): boolean { return this.has(provider); } hasAuth(provider: string): boolean { return this.has(provider); }
async set(provider: string, credential: StoredCredential): Promise<void> { async set(provider: string, credential: StoredCredential): Promise<void> {
await this.withLock(async (current) => { current[provider] = credential; }); this.assertRefFromKey(provider);
await this.withLock(async current => {
const target = this.resolveWriteTarget(provider, current, true)!;
current[formatProviderInstanceKey(target)] = credential;
return { result: undefined, changed: true };
});
} }
async setInstance(ref: ProviderInstanceRef, credential: StoredCredential): Promise<void> { this.assertRef(ref); await this.withLock(async current => { current[formatProviderInstanceKey(ref)] = credential; return { result: undefined, changed: true }; }); }
private async removeRef(ref: ProviderInstanceRef): Promise<void> { await this.withLock(async current => { const key = formatProviderInstanceKey(ref); if (!isStoredAuthCredential(current[key])) return { result: undefined, changed: false }; delete current[key]; const defaults = readDefaultInstanceMap(current); if (defaults[ref.providerId] === ref.instanceId) { const next = { ...defaults }; delete next[ref.providerId]; current.__fusionDefaultInstances = next; } return { result: undefined, changed: true }; }); }
async remove(provider: string): Promise<void> { async remove(provider: string): Promise<void> {
await this.withLock(async (current) => { delete current[provider]; }); this.assertRefFromKey(provider);
await this.withLock(async current => { const target = this.resolveWriteTarget(provider, current, false); if (!target || !isStoredAuthCredential(current[formatProviderInstanceKey(target)])) return { result: undefined, changed: false }; const key = formatProviderInstanceKey(target); delete current[key]; const defaults = readDefaultInstanceMap(current); if (defaults[target.providerId] === target.instanceId) { const next = { ...defaults }; delete next[target.providerId]; current.__fusionDefaultInstances = next; } return { result: undefined, changed: true }; });
} }
async removeInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.removeRef(ref); }
async logout(provider: string): Promise<void> { await this.remove(provider); } async logout(provider: string): Promise<void> { await this.remove(provider); }
async getApiKey(provider: string): Promise<string | undefined> { async setDefaultInstance(ref: ProviderInstanceRef): Promise<void> { this.assertRef(ref); await this.withLock(async current => { if (!this.credential(ref, current)) throw new Error("Cannot set default for a missing credential instance"); const defaults = { ...readDefaultInstanceMap(current), [ref.providerId]: ref.instanceId }; current.__fusionDefaultInstances = defaults; return { result: undefined, changed: true }; }); }
return resolveStoredCredentialApiKey(provider, this.get(provider)); async getApiKey(provider: string): Promise<string | undefined> { return resolveStoredCredentialApiKey(provider, this.get(provider)); }
} async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
getOAuthProviders(): Array<{ id: string; name: string }> { this.assertRefFromKey(provider);
return [ return this.withLock(async current => {
{ id: "anthropic", name: "Anthropic" }, const target = this.resolveWriteTarget(provider, current, false);
{ id: "openai-codex", name: "OpenAI Codex" }, if (!target || !this.credential(target, current)) return { result: undefined, changed: false };
{ id: "github-copilot", name: "GitHub Copilot" }, const next = await fn(this.credential(target, current));
]; if (next !== undefined) {
} current[formatProviderInstanceKey(target)] = next;
setModelRuntime(modelRuntime: ModelRuntime): void { return { result: next, changed: true };
this.modelRuntime = modelRuntime; }
return { result: this.credential(target, current), changed: false };
});
} }
getOAuthProviders(): Array<{ id: string; name: string }> { return [{ id: "anthropic", name: "Anthropic" }, { id: "openai-codex", name: "OpenAI Codex" }, { id: "github-copilot", name: "GitHub Copilot" }]; }
setModelRuntime(modelRuntime: ModelRuntime): void { this.modelRuntime = modelRuntime; }
async login(provider: string, callbacks: unknown): Promise<void> { async login(provider: string, callbacks: unknown): Promise<void> {
if (!this.modelRuntime) throw new Error("OAuth login requires a ModelRuntime-backed Fusion auth storage"); if (!this.modelRuntime) throw new Error("OAuth login requires a ModelRuntime-backed Fusion auth storage");
const legacy = callbacks as { const legacy = callbacks as { onAuth?: (info: { url: string; instructions?: string }) => void; onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void; onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>; onProgress?: (message: string) => void; signal?: AbortSignal; };
onAuth?: (info: { url: string; instructions?: string }) => void; const interaction: AuthInteraction = { signal: legacy.signal, prompt: async prompt => legacy.onPrompt?.({ message: prompt.message, placeholder: "placeholder" in prompt ? prompt.placeholder : undefined }) ?? "", notify: event => { if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions }); else if (event.type === "device_code") legacy.onDeviceCode?.(event); else if (event.type === "progress") legacy.onProgress?.(event.message); } };
onDeviceCode?: (info: { userCode: string; verificationUri: string; intervalSeconds?: number; expiresInSeconds?: number }) => void; await this.modelRuntime.login(provider, "oauth", interaction); this.reload();
onPrompt?: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
};
const interaction: AuthInteraction = {
signal: legacy.signal,
prompt: async (prompt) => legacy.onPrompt?.({
message: prompt.message,
placeholder: "placeholder" in prompt ? prompt.placeholder : undefined,
}) ?? "",
notify: (event) => {
if (event.type === "auth_url") legacy.onAuth?.({ url: event.url, instructions: event.instructions });
else if (event.type === "device_code") legacy.onDeviceCode?.(event);
else if (event.type === "progress") legacy.onProgress?.(event.message);
},
};
await this.modelRuntime.login(provider, "oauth", interaction);
this.reload();
}
async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise<StoredCredential | undefined>): Promise<StoredCredential | undefined> {
return this.withLock(async (current) => {
const next = await fn(current[provider]);
if (next !== undefined) current[provider] = next;
return current[provider];
});
} }
} }
@@ -249,7 +295,6 @@ apply so a single stuck token doesn't get hammered).
*/ */
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000; const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
const ANTHROPIC_PROVIDER_ID = "anthropic"; const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000; const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
export function getHomeDir(): string { export function getHomeDir(): string {
@@ -976,11 +1021,14 @@ export function createFusionAuthStorage(): FusionAuthStorage {
} }
if (prop === "get") { if (prop === "get") {
return (provider: string) => selectVisibleStoredCredential(provider); return (provider: string) => parseProviderInstanceKey(provider)
? selectVisibleStoredCredential(provider)
: undefined;
} }
if (prop === "has") { if (prop === "has") {
return (provider: string) => { return (provider: string) => {
if (!parseProviderInstanceKey(provider)) return false;
if (provider === ANTHROPIC_PROVIDER_ID) { if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential(); return hasVisibleAnthropicCredential();
} }
@@ -996,6 +1044,7 @@ export function createFusionAuthStorage(): FusionAuthStorage {
if (prop === "hasAuth") { if (prop === "hasAuth") {
return (provider: string) => { return (provider: string) => {
if (!parseProviderInstanceKey(provider)) return false;
if (provider === ANTHROPIC_PROVIDER_ID) { if (provider === ANTHROPIC_PROVIDER_ID) {
return hasVisibleAnthropicCredential(); return hasVisibleAnthropicCredential();
} }
@@ -1056,12 +1105,13 @@ export function createFusionAuthStorage(): FusionAuthStorage {
return false; return false;
} }
return true; return true;
}); }).sort();
}; };
} }
if (prop === "getApiKey") { if (prop === "getApiKey") {
return async (provider: string) => { return async (provider: string) => {
if (!parseProviderInstanceKey(provider)) return undefined;
await supplementalHydration; await supplementalHydration;
if (provider === ANTHROPIC_PROVIDER_ID) { if (provider === ANTHROPIC_PROVIDER_ID) {
return resolveAnthropicRuntimeApiKey(); return resolveAnthropicRuntimeApiKey();