From a6c60e1592dedeedf6f03ad89f5e3f34a0917f10 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 7 Jul 2026 08:32:58 -0700 Subject: [PATCH] FN-7625: make auth provider list a static catalog, not runtime-derived Authentication settings previously enumerated providers straight from pi AuthStorage's live runtime registry, so connecting a runtime plugin (e.g. Hermes Runtime) could narrow/collapse the visible provider list. This adds a static, hand-maintained supported-provider catalog and unions it with storage-reported providers so presence in the list is deterministic while status stays live. - Add packages/dashboard/src/routes/auth-provider-catalog.ts with STATIC_OAUTH_PROVIDER_CATALOG, STATIC_API_KEY_PROVIDER_CATALOG, and unionProviderCatalog() (catalog always wins on presence; runtime-only extras still surface; runtime name wins on name conflicts). - Update register-auth-routes.ts's GET /api/auth/status to union the static catalogs with storage.getOAuthProviders()/getApiKeyProviders() instead of relying solely on runtime-reported providers. - Extend routes-auth.test.ts coverage for the new catalog-union behavior (provider presence stable across narrowed runtime registries, extras preserved, name precedence). - Add changeset fn-7625-static-auth-provider-catalog.md (patch, fix). Files changed: .changeset/fn-7625-static-auth-provider-catalog.md | 7 + .../dashboard/src/__tests__/routes-auth.test.ts | 180 +++++++++++++++++++-- .../dashboard/src/routes/auth-provider-catalog.ts | 93 +++++++++++ .../dashboard/src/routes/register-auth-routes.ts | 25 ++- 4 files changed, 291 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7625 Fusion-Task-Lineage: be984497-b881-4a8f-9860-544617e2b5f7 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7625-static-auth-provider-catalog.md | 7 + .../src/__tests__/routes-auth.test.ts | 180 +++++++++++++++++- .../src/routes/auth-provider-catalog.ts | 93 +++++++++ .../src/routes/register-auth-routes.ts | 25 ++- 4 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 .changeset/fn-7625-static-auth-provider-catalog.md create mode 100644 packages/dashboard/src/routes/auth-provider-catalog.ts diff --git a/.changeset/fn-7625-static-auth-provider-catalog.md b/.changeset/fn-7625-static-auth-provider-catalog.md new file mode 100644 index 0000000000..57c57f1165 --- /dev/null +++ b/.changeset/fn-7625-static-auth-provider-catalog.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Authentication settings always lists all supported providers, regardless of connected runtime plugins. +category: fix +dev: GET /api/auth/status now enumerates a static supported-provider catalog (union with storage-reported providers) and uses runtime/auth state only to annotate per-provider status; connecting a runtime plugin (e.g. Hermes Runtime) no longer collapses the provider list. diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index 5e44a35166..f4a5bd4746 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -932,11 +932,34 @@ describe("GET /auth/status", () => { // Filter out synthetic CLI providers — they have dedicated route tests. // Structural assertions here are about OAuth + API-key paths only. const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp"); - expect(providers).toEqual([ - { id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }, - { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, - { id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }, + /* + FN-7625: the static catalog (anthropic-subscription/github-copilot/openai-codex + OAuth + the full API-key catalog) is always present, unioned with whatever the + mocked storage additionally reports — even when storage only reports a narrow + subset (github-copilot + openrouter + kimi-coding here). + */ + expect(providers.map((p: any) => p.id)).toEqual([ + "anthropic-subscription", + "github-copilot", + "openai-codex", + "anthropic-api-key", + "brave", + "kimi-coding", + "minimax", + "openrouter", + "opencode-go", + "tavily", + "zai", ]); + const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); + expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }); + const openrouter = providers.find((p: any) => p.id === "openrouter"); + expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }); + const kimiCoding = providers.find((p: any) => p.id === "kimi-coding"); + expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }); + // Catalog-only entries (not reported by storage) still surface, present-but-unauthenticated. + const brave = providers.find((p: any) => p.id === "brave"); + expect(brave).toEqual({ id: "brave", name: "Brave Search", authenticated: false, type: "api_key" }); expect(authStorage.reload).toHaveBeenCalled(); }); @@ -1026,13 +1049,35 @@ describe("GET /auth/status", () => { expect(res.status).toBe(200); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp"); - expect(providers).toEqual([ - { id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }, - { id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true }, - { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, - { id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }, - { id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" }, + /* + FN-7625: catalog ids remain present even though storage only reported a + narrow subset, and a storage-reported id NOT in the catalog ("acme-extension") + still surfaces — union, never intersection, with runtime state. + */ + expect(providers.map((p: any) => p.id)).toEqual([ + "anthropic-subscription", + "github-copilot", + "openai-codex", + "anthropic-api-key", + "brave", + "kimi-coding", + "minimax", + "openrouter", + "opencode-go", + "tavily", + "zai", + "acme-extension", ]); + const githubCopilot = providers.find((p: any) => p.id === "github-copilot"); + expect(githubCopilot).toEqual({ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false }); + const openaiCodex = providers.find((p: any) => p.id === "openai-codex"); + expect(openaiCodex).toEqual({ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true }); + const openrouter = providers.find((p: any) => p.id === "openrouter"); + expect(openrouter).toEqual({ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }); + const kimiCoding = providers.find((p: any) => p.id === "kimi-coding"); + expect(kimiCoding).toEqual({ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }); + const acmeExtension = providers.find((p: any) => p.id === "acme-extension"); + expect(acmeExtension).toEqual({ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" }); }); it.each(["https://my-host.example.com", undefined])( @@ -1462,6 +1507,121 @@ describe("GET /auth/status", () => { expect(res.status).toBe(500); expect(res.body.error).toBe("storage error"); }); + + /* + FN-7625 symptom verification: connecting a runtime plugin (e.g. Hermes Runtime) + can narrow pi AuthStorage's live provider registry — storage.getOAuthProviders() + and storage.getApiKeyProviders() collapse to whatever that plugin still exposes. + Before the fix, GET /auth/status enumerated `providers` directly from those live + reads, so the response's provider set collapsed along with the narrowed registry. + The static catalog fix must keep provider *presence* deterministic — identical + full-catalog ids — across a full registry, a narrowed/empty registry (simulating + a connected runtime plugin), AND a registry that reports ids outside the catalog + (union, never intersection). Only per-provider `authenticated`/`expired` may vary. + */ + describe("FN-7625: provider list is a static catalog independent of runtime/plugin connection state", () => { + const FULL_OAUTH_CATALOG_IDS = ["anthropic-subscription", "github-copilot", "openai-codex"]; + const FULL_API_KEY_CATALOG_IDS = [ + "anthropic-api-key", + "brave", + "kimi-coding", + "minimax", + "openrouter", + "opencode-go", + "tavily", + "zai", + ]; + const FULL_CATALOG_IDS = [...FULL_OAUTH_CATALOG_IDS, ...FULL_API_KEY_CATALOG_IDS]; + + function nonCliProviderIds(res: any): string[] { + return res.body.providers + .filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp") + .map((p: any) => p.id); + } + + it("enumerates the identical full catalog whether storage reports the full set or a narrowed/empty set (Hermes Runtime connection simulation)", async () => { + // Permutation 1: no runtime plugin connected — storage reports the full + // upstream registry. + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + { id: "github-copilot", name: "GitHub Copilot" }, + { id: "openai-codex", name: "OpenAI Codex" }, + ]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([ + { id: "anthropic-api-key", name: "Anthropic API Key" }, + { id: "brave", name: "Brave Search" }, + { id: "kimi-coding", name: "Kimi" }, + { id: "minimax", name: "Minimax" }, + { id: "openrouter", name: "OpenRouter" }, + { id: "opencode-go", name: "Opencode (Go)" }, + { id: "tavily", name: "Tavily" }, + { id: "zai", name: "Zai" }, + ]); + const fullRes = await GET(app, "/api/auth/status"); + expect(fullRes.status).toBe(200); + expect(nonCliProviderIds(fullRes)).toEqual(FULL_CATALOG_IDS); + + // Permutation 2: a runtime plugin (e.g. Hermes Runtime) connects and + // narrows the live registry down to almost nothing — the exact + // reproduction from the task's Symptom Verification section. + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "github-copilot", name: "GitHub Copilot" }, + ]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([]); + const narrowedRes = await GET(app, "/api/auth/status"); + expect(narrowedRes.status).toBe(200); + expect(nonCliProviderIds(narrowedRes)).toEqual(FULL_CATALOG_IDS); + + // Permutation 3: another runtime plugin narrows the registry to a + // completely empty set (Paperclip / OpenClaw / Droid Runtime scenario). + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([]); + const emptyRes = await GET(app, "/api/auth/status"); + expect(emptyRes.status).toBe(200); + expect(nonCliProviderIds(emptyRes)).toEqual(FULL_CATALOG_IDS); + }); + + it("still tracks authenticated/expired from storage while presence stays static", async () => { + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "github-copilot"); + (authStorage.hasApiKey as ReturnType).mockImplementation((provider: string) => provider === "openrouter"); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + // Catalog provider missing from storage still appears, present-but-unauthenticated. + const anthropicSubscription = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(anthropicSubscription).toMatchObject({ authenticated: false }); + // Catalog provider storage marks authenticated stays authenticated. + const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot"); + expect(githubCopilot).toMatchObject({ authenticated: true }); + const openrouter = res.body.providers.find((p: any) => p.id === "openrouter"); + expect(openrouter).toMatchObject({ authenticated: true }); + const brave = res.body.providers.find((p: any) => p.id === "brave"); + expect(brave).toMatchObject({ authenticated: false }); + }); + + it("surfaces a storage-reported provider absent from the catalog (union, never intersection) and keeps the Anthropic alias de-duplicated", async () => { + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + { id: "a-brand-new-upstream-oauth-provider", name: "Brand New Provider" }, + ]); + (authStorage.getApiKeyProviders as ReturnType).mockReturnValue([]); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const providerIds = res.body.providers.map((p: any) => p.id); + // Union: the new upstream provider surfaces alongside the static catalog. + expect(providerIds).toContain("a-brand-new-upstream-oauth-provider"); + // The anthropic OAuth id is exposed only as the synthetic anthropic-subscription + // id — no duplicate raw "anthropic" OAuth card. + expect(providerIds).toContain("anthropic-subscription"); + expect(providerIds).not.toContain("anthropic"); + expect(providerIds.filter((id: string) => id === "anthropic-subscription")).toHaveLength(1); + }); + }); }); describe("POST /auth/claude-cli", () => { diff --git a/packages/dashboard/src/routes/auth-provider-catalog.ts b/packages/dashboard/src/routes/auth-provider-catalog.ts new file mode 100644 index 0000000000..c240911114 --- /dev/null +++ b/packages/dashboard/src/routes/auth-provider-catalog.ts @@ -0,0 +1,93 @@ +/** + * Static supported-provider catalog for the Authentication settings page. + * + * FNXC:ProviderAuth 2026-07-07-00:00: + * FN-7625: the Authentication page (Settings → Authentication) must render a + * fixed catalog of every provider Fusion supports, independent of which pi + * runtime plugins are currently connected. `GET /api/auth/status` previously + * enumerated providers straight from pi `AuthStorage`'s *live* runtime + * registry (`storage.getOAuthProviders()` / `storage.getApiKeyProviders()`). + * That registry is mutable at runtime — a connected runtime plugin (Hermes + * Runtime, Paperclip, OpenClaw, Droid Runtime, ...) can narrow it, which + * collapsed the visible provider list the moment such a plugin connected, + * hiding providers Fusion otherwise fully supports. + * + * The fix: presence in `/auth/status`'s `providers` array must come from this + * static catalog UNIONED with whatever storage reports — never an + * intersection with runtime state. A provider's *status* (authenticated / + * expired / keyHint) is still read live from storage per request; only its + * *presence* in the list is made deterministic. + * + * Sourcing note: this mirrors two existing canonical lists that dashboard + * cannot import directly — + * - OAuth: pi-ai's built-in OAuth provider registry (anthropic, + * github-copilot, openai-codex — see `@earendil-works/pi-ai`'s + * `utils/oauth/index.ts` `BUILT_IN_OAUTH_PROVIDERS`), which is not + * exported as a public catalog. + * - API key: `packages/cli/src/commands/provider-auth.ts`'s + * `BUILT_IN_API_KEY_PROVIDERS`, the existing canonical list used to + * synthesize `getApiKeyProviders()` for the CLI/dashboard auth wrapper. + * `packages/cli` depends on `packages/dashboard` (not the reverse), so + * importing it here would introduce a circular workspace dependency. + * Kept as a small hand-maintained mirror; update alongside + * `provider-auth.ts`'s `BUILT_IN_API_KEY_PROVIDERS` when providers are + * added/removed there. The synthetic CLI providers (`claude-cli`, + * `droid-cli`, `cursor-cli`, `llama-cpp`) are NOT part of this catalog — they + * stay on their existing dedicated injection path in + * `register-auth-routes.ts`. + */ + +export interface AuthProviderCatalogEntry { + id: string; + name: string; +} + +/** Static catalog of OAuth-backed providers Fusion supports. */ +export const STATIC_OAUTH_PROVIDER_CATALOG: AuthProviderCatalogEntry[] = [ + // Raw upstream id; toAuthStatusProvider() maps this to the synthetic + // `anthropic-subscription` UI id so it never collides with the + // `anthropic-api-key` API-key card. + { id: "anthropic", name: "Anthropic (Claude Pro/Max)" }, + { id: "github-copilot", name: "GitHub Copilot" }, + { id: "openai-codex", name: "OpenAI (ChatGPT Plus/Pro)" }, +]; + +/** Static catalog of API-key-backed providers Fusion supports. */ +export const STATIC_API_KEY_PROVIDER_CATALOG: AuthProviderCatalogEntry[] = [ + { id: "anthropic-api-key", name: "Anthropic API Key" }, + { id: "brave", name: "Brave Search" }, + { id: "kimi-coding", name: "Kimi" }, + { id: "minimax", name: "Minimax" }, + { id: "openrouter", name: "OpenRouter" }, + { id: "opencode-go", name: "Opencode (Go)" }, + { id: "tavily", name: "Tavily" }, + { id: "zai", name: "Zai" }, +]; + +/** + * Union a static catalog with a live/runtime-reported provider list, keyed + * by id. Catalog entries always survive regardless of what `runtime` + * contains (never an intersection); a runtime-reported provider whose id is + * NOT in the catalog still surfaces (a genuinely new upstream provider must + * not be dropped). When both sides report the same id, the runtime-reported + * `name` wins (it reflects the live registry's current display name), while + * the catalog still guarantees the id's presence. + */ +export function unionProviderCatalog( + catalog: AuthProviderCatalogEntry[], + runtime: AuthProviderCatalogEntry[], +): AuthProviderCatalogEntry[] { + const byId = new Map(); + for (const entry of catalog) { + byId.set(entry.id, entry); + } + const extras: AuthProviderCatalogEntry[] = []; + for (const entry of runtime) { + if (byId.has(entry.id)) { + byId.set(entry.id, entry); + } else { + extras.push(entry); + } + } + return [...catalog.map((entry) => byId.get(entry.id) ?? entry), ...extras]; +} diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index f5402fce53..ec2d480d87 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -12,6 +12,11 @@ import { clearUsageCache } from "../usage.js"; import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import type { AuthStorageLike } from "../routes.js"; import type { ApiRouteRegistrar } from "./types.js"; +import { + STATIC_API_KEY_PROVIDER_CATALOG, + STATIC_OAUTH_PROVIDER_CATALOG, + unionProviderCatalog, +} from "./auth-provider-catalog.js"; export type DeviceCodeInfo = { userCode: string; @@ -512,7 +517,15 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined; const storage = getAuthStorage(); storage.reload(); - const oauthProviders = storage.getOAuthProviders(); + /* + FNXC:ProviderAuth 2026-07-07-00:00: + FN-7625: enumerate OAuth + API-key providers from the static catalog + UNIONED with whatever storage currently reports, so a connected + runtime plugin narrowing storage.getOAuthProviders()/getApiKeyProviders() + never removes a provider from the list — only per-provider status below + may vary with runtime/auth state. See auth-provider-catalog.ts. + */ + const oauthProviders = unionProviderCatalog(STATIC_OAUTH_PROVIDER_CATALOG, storage.getOAuthProviders()); const providers: { id: string; name: string; @@ -564,9 +577,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }; })); - // Include API-key-backed providers if supported - if (storage.getApiKeyProviders) { - const apiKeyProviders = storage.getApiKeyProviders(); + // Include API-key-backed providers. Presence is the static catalog + // unioned with anything storage additionally reports (FN-7625) — + // storage.getApiKeyProviders may be absent/narrowed, but the catalog + // entries must still surface as present-but-unauthenticated. + { + const runtimeApiKeyProviders = storage.getApiKeyProviders ? storage.getApiKeyProviders() : []; + const apiKeyProviders = unionProviderCatalog(STATIC_API_KEY_PROVIDER_CATALOG, runtimeApiKeyProviders); for (const p of apiKeyProviders) { let keyHint: string | undefined; if (storage.get) {