From 182e3fdbe8052411181ed8368ab69413d8097822 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 21:56:30 -0700 Subject: [PATCH] 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) --- .changeset/fn-8651-provider-instances.md | 7 + docs/secrets.md | 8 + .../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 ++++++ .../auth-storage-concurrency.test.ts | 13 ++ .../__tests__/auth-storage-instances.test.ts | 75 ++++++ packages/engine/src/auth-storage.ts | 214 +++++++++++------- 10 files changed, 367 insertions(+), 86 deletions(-) create mode 100644 .changeset/fn-8651-provider-instances.md create mode 100644 packages/core/src/provider-instance.test.ts create mode 100644 packages/core/src/provider-instance.ts create mode 100644 packages/engine/src/__tests__/auth-storage-instances.test.ts diff --git a/.changeset/fn-8651-provider-instances.md b/.changeset/fn-8651-provider-instances.md new file mode 100644 index 0000000000..32a4745911 --- /dev/null +++ b/.changeset/fn-8651-provider-instances.md @@ -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. diff --git a/docs/secrets.md b/docs/secrets.md index f704276e3d..7dfbe1a720 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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. +## 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 - 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. diff --git a/packages/core/src/__tests__/oauth-credential-interop.test.ts b/packages/core/src/__tests__/oauth-credential-interop.test.ts index 37adab7f9d..6b4b14b7fe 100644 --- a/packages/core/src/__tests__/oauth-credential-interop.test.ts +++ b/packages/core/src/__tests__/oauth-credential-interop.test.ts @@ -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", () => { const tempDir = mkdtempSync(join(tmpdir(), "fusion-oauth-interop-")); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86a0d39abd..9ce8857abb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2389,8 +2389,24 @@ export { getCodexCliAuthPath, readStoredCredentialsFromAuthFile, shouldHydrateStoredCredential, + isStoredAuthCredential, } 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 ───────────────────────────────────────── export { getErrorMessage } from "./error-message.js"; diff --git a/packages/core/src/oauth-credential-interop.ts b/packages/core/src/oauth-credential-interop.ts index 4573411cad..1ab07d88fc 100644 --- a/packages/core/src/oauth-credential-interop.ts +++ b/packages/core/src/oauth-credential-interop.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { isDefaultProviderInstance, parseProviderInstanceKey } from "./provider-instance.js"; export type StoredAuthCredential = { type?: string; @@ -82,7 +83,7 @@ function getLastRefreshFallbackExpiryMs(lastRefresh: unknown): number | undefine 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)) { return false; } @@ -275,11 +276,17 @@ export function readStoredCredentialsFromAuthFile(authPath: string): Record = {}; - for (const [providerId, value] of Object.entries(parsed as Record)) { - if (!isStoredAuthCredential(value)) { + for (const [key, value] of Object.entries(parsed as Record)) { + const ref = parseProviderInstanceKey(key); + if (!ref || !isDefaultProviderInstance(ref.instanceId) || !isStoredAuthCredential(value)) { 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; } catch { diff --git a/packages/core/src/provider-instance.test.ts b/packages/core/src/provider-instance.test.ts new file mode 100644 index 0000000000..71a4cd37a0 --- /dev/null +++ b/packages/core/src/provider-instance.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/provider-instance.ts b/packages/core/src/provider-instance.ts new file mode 100644 index 0000000000..29c83bed7a --- /dev/null +++ b/packages/core/src/provider-instance.ts @@ -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; +} diff --git a/packages/engine/src/__tests__/auth-storage-concurrency.test.ts b/packages/engine/src/__tests__/auth-storage-concurrency.test.ts index 6a9f20304a..08d1e55374 100644 --- a/packages/engine/src/__tests__/auth-storage-concurrency.test.ts +++ b/packages/engine/src/__tests__/auth-storage-concurrency.test.ts @@ -280,6 +280,19 @@ describe("createFusionAuthStorage — concurrent cross-process coordination", () 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 () => { const now = Date.now(); const instanceA = createFusionAuthStorage(); diff --git a/packages/engine/src/__tests__/auth-storage-instances.test.ts b/packages/engine/src/__tests__/auth-storage-instances.test.ts new file mode 100644 index 0000000000..bf4ce48a07 --- /dev/null +++ b/packages/engine/src/__tests__/auth-storage-instances.test.ts @@ -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) => { 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(); + }); +}); diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index 24609d15fd..bff568caa6 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -9,6 +9,15 @@ import { readStoredCredentialsFromAuthFile, shouldHydrateStoredCredential, type StoredAuthCredential, + type ProviderInstanceRef, + ANTHROPIC_SUBSCRIPTION_PROVIDER_ID, + DEFAULT_PROVIDER_INSTANCE_ID, + formatProviderInstanceKey, + isDefaultProviderInstance, + isReservedAuthStorageKey, + isStoredAuthCredential, + isValidProviderId, + parseProviderInstanceKey, } from "@fusion/core"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; import type { AuthInteraction, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai"; @@ -22,6 +31,12 @@ export interface FusionAuthStorage { list(): string[]; has(provider: string): boolean; hasAuth(provider: string): boolean; + listInstances(providerId: string): ProviderInstanceRef[]; + getInstance(ref: ProviderInstanceRef): StoredCredential | undefined; + setInstance(ref: ProviderInstanceRef, credential: StoredCredential): Promise; + removeInstance(ref: ProviderInstanceRef): Promise; + getDefaultInstance(providerId: string): ProviderInstanceRef | undefined; + setDefaultInstance(ref: ProviderInstanceRef): Promise; set(provider: string, credential: StoredCredential): Promise; remove(provider: string): Promise; logout(provider: string): Promise; @@ -85,109 +100,140 @@ async function withOAuthRefreshLock( } } +type AuthFileData = Record; +type DefaultInstanceMap = Record; + +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 { - private data: Record = {}; + private data: AuthFileData = {}; private modelRuntime: ModelRuntime | undefined; - constructor(private readonly authPath: string) { - this.reload(); - } - + constructor(private readonly authPath: string) { this.reload(); } private ensureFile(): void { const parent = dirname(this.authPath); if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o700 }); - if (!existsSync(this.authPath)) { - writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 }); - chmodSync(this.authPath, 0o600); - } + if (!existsSync(this.authPath)) { writeFileSync(this.authPath, "{}", { encoding: "utf-8", mode: 0o600 }); chmodSync(this.authPath, 0o600); } } - - private readCurrent(): Record { + private readCurrent(): AuthFileData { try { - return JSON.parse(readFileSync(this.authPath, "utf-8")) as Record; - } catch { - return {}; - } + const parsed: unknown = JSON.parse(readFileSync(this.authPath, "utf-8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as AuthFileData : {}; + } catch { return {}; } } - - private async withLock(fn: (current: Record) => Promise): Promise { + private async withLock(fn: (current: AuthFileData) => Promise<{ result: T; changed: boolean }>): Promise { return enqueueAuthWrite(this.authPath, async () => { - this.ensureFile(); - const release = await lockfile.lock(this.authPath, AUTH_LOCK_OPTIONS); + 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; + const current = this.readCurrent(); const { result, changed } = await fn(current); + if (changed) { 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(); - } + } finally { await release(); } }); } - - reload(): void { - this.ensureFile(); - this.data = this.readCurrent(); + reload(): void { this.ensureFile(); this.data = this.readCurrent(); } + private parseReadKey(key: string): ProviderInstanceRef | undefined { return parseProviderInstanceKey(key); } + private assertRef(ref: ProviderInstanceRef): ProviderInstanceRef { + // format validates both ids and rejects reserved provider names before any mutator locks. + formatProviderInstanceKey(ref); return ref; } - - get(provider: string): StoredCredential | undefined { return this.data[provider]; } - getAll(): Record { return { ...this.data }; } - list(): string[] { return Object.keys(this.data); } - has(provider: string): boolean { return Boolean(this.data[provider]); } + private resolveDefaultInstance(providerId: string, data: AuthFileData): ProviderInstanceRef | undefined { + if (!isValidProviderId(providerId) || isReservedAuthStorageKey(providerId)) return undefined; + const pointer = readDefaultInstanceMap(data)[providerId]; + if (typeof pointer === "string") { + 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 { const result: Record = {}; 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); } async set(provider: string, credential: StoredCredential): Promise { - 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 { this.assertRef(ref); await this.withLock(async current => { current[formatProviderInstanceKey(ref)] = credential; return { result: undefined, changed: true }; }); } + private async removeRef(ref: ProviderInstanceRef): Promise { 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 { - 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 { this.assertRef(ref); await this.removeRef(ref); } async logout(provider: string): Promise { await this.remove(provider); } - async getApiKey(provider: string): Promise { - return resolveStoredCredentialApiKey(provider, this.get(provider)); - } - 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 setDefaultInstance(ref: ProviderInstanceRef): Promise { 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 }; }); } + async getApiKey(provider: string): Promise { return resolveStoredCredentialApiKey(provider, this.get(provider)); } + async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise): Promise { + this.assertRefFromKey(provider); + return this.withLock(async current => { + const target = this.resolveWriteTarget(provider, current, false); + if (!target || !this.credential(target, current)) return { result: undefined, changed: false }; + const next = await fn(this.credential(target, current)); + if (next !== undefined) { + current[formatProviderInstanceKey(target)] = next; + return { result: next, changed: true }; + } + 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 { if (!this.modelRuntime) throw new Error("OAuth login requires a ModelRuntime-backed Fusion auth storage"); - 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; - 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): Promise { - return this.withLock(async (current) => { - const next = await fn(current[provider]); - if (next !== undefined) current[provider] = next; - return current[provider]; - }); + 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; 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(); } } @@ -249,7 +295,6 @@ apply so a single stuck token doesn't get hammered). */ const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000; const ANTHROPIC_PROVIDER_ID = "anthropic"; -const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000; export function getHomeDir(): string { @@ -976,11 +1021,14 @@ export function createFusionAuthStorage(): FusionAuthStorage { } if (prop === "get") { - return (provider: string) => selectVisibleStoredCredential(provider); + return (provider: string) => parseProviderInstanceKey(provider) + ? selectVisibleStoredCredential(provider) + : undefined; } if (prop === "has") { return (provider: string) => { + if (!parseProviderInstanceKey(provider)) return false; if (provider === ANTHROPIC_PROVIDER_ID) { return hasVisibleAnthropicCredential(); } @@ -996,6 +1044,7 @@ export function createFusionAuthStorage(): FusionAuthStorage { if (prop === "hasAuth") { return (provider: string) => { + if (!parseProviderInstanceKey(provider)) return false; if (provider === ANTHROPIC_PROVIDER_ID) { return hasVisibleAnthropicCredential(); } @@ -1056,12 +1105,13 @@ export function createFusionAuthStorage(): FusionAuthStorage { return false; } return true; - }); + }).sort(); }; } if (prop === "getApiKey") { return async (provider: string) => { + if (!parseProviderInstanceKey(provider)) return undefined; await supplementalHydration; if (provider === ANTHROPIC_PROVIDER_ID) { return resolveAnthropicRuntimeApiKey();