diff --git a/.changeset/fn-7630-hermes-runtime-additive.md b/.changeset/fn-7630-hermes-runtime-additive.md new file mode 100644 index 0000000000..cbcc786213 --- /dev/null +++ b/.changeset/fn-7630-hermes-runtime-additive.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Hermes Runtime is now additive — connecting it no longer hides your custom providers, models, or auth options. +category: fix +dev: Audited the Hermes Runtime plugin (onLoad/onUnload, CLI-spawn/probe seams) and register-model-routes.ts/register-auth-routes.ts against GitHub #1931. Confirmed the reported customProviders suppression was already fixed generically (unrelated to Hermes) and that Hermes's PluginContext has no reference to AuthStorage/ModelRegistry, so it cannot mutate either store. Added FNXC documentation comments locking in the additive-runtime invariant and regression coverage across the model-picker (/api/models), custom-provider CRUD, and auth-status surfaces proving a connected Hermes runtime never narrows them. Item 3 (static auth catalog) remains owned by FN-7625; item 1 (additive Hermes-model surfacing in the picker) is deferred to a follow-up task (FN-7636) pending a non-blocking CLI-spawn caching strategy. diff --git a/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts b/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts new file mode 100644 index 0000000000..487e5c1270 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-auth-routes-hermes-additive.test.ts @@ -0,0 +1,108 @@ +/* +FNXC:ProviderAuth 2026-07-07-08:20: +FN-7630 (GitHub #1931) item 3 coordination: the Hermes Runtime connection +must never CAUSE the /api/auth/status provider surface to shrink. The +static-catalog rewrite of that surface is owned by FN-7625 (still in +Planning as of this task) and is intentionally NOT reimplemented here. This +suite proves the narrower, in-scope invariant: given the exact same +AuthStorage-reported provider set, register-auth-routes.ts's /auth/status +handler enumerates every entry storage.getOAuthProviders()/getApiKeyProviders() +report — regardless of whether a Hermes-labeled entry is present alongside +them — so a connected Hermes runtime contributing its own provider entry can +only ever ADD to the response, never remove sibling entries. +*/ +import { describe, expect, it, vi } from "vitest"; +import type { Router } from "express"; +import { registerAuthRoutes } from "../routes/register-auth-routes.js"; + +function setup(oauthProviders: Array<{ id: string; name: string }>, apiKeyProviders: Array<{ id: string; name: string }>) { + const getHandlers = new Map void }) => Promise>(); + const postHandlers = new Map(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + getHandlers.set(path, handler); + }), + post: vi.fn((path: string, handler: unknown) => { + postHandlers.set(path, handler); + }), + put: vi.fn(), + delete: vi.fn(), + } as unknown as Router; + + const authStorage = { + reload: vi.fn(), + getOAuthProviders: vi.fn(() => oauthProviders), + getApiKeyProviders: vi.fn(() => apiKeyProviders), + hasAuth: vi.fn(() => false), + hasApiKey: vi.fn(() => false), + get: vi.fn(() => undefined), + }; + + const rethrowAsApiError = (err: unknown) => { + throw err; + }; + + registerAuthRoutes({ + router, + // No `store` — this test deliberately isolates the AuthStorage-derived + // provider surface (getOAuthProviders/getApiKeyProviders) from the + // settings-toggle-gated synthetic CLI providers (claude-cli/droid-cli/ + // cursor-cli/llama-cpp), which are covered elsewhere and are not part of + // the Hermes-connection question. + store: undefined, + options: { authStorage }, + getScopedStore: vi.fn(), + rethrowAsApiError, + } as never); + + return { handler: getHandlers.get("/auth/status")!, authStorage }; +} + +async function callStatus(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { + const json = vi.fn(); + await handler({ headers: {} }, { json }); + return json.mock.calls[0][0] as { providers: Array<{ id: string; name: string }> }; +} + +describe("FN-7630: Hermes runtime additive — /api/auth/status", () => { + it("does not shrink the enumerated provider surface when a Hermes-labeled entry is present", async () => { + const baselineOauth = [{ id: "github-copilot", name: "GitHub Copilot" }]; + const baselineApiKey = [{ id: "openai", name: "OpenAI" }]; + + const disconnected = setup(baselineOauth, baselineApiKey); + const disconnectedResponse = await callStatus(disconnected.handler); + + // Simulate a connected Hermes runtime by having AuthStorage report an + // additional "hermes" provider entry alongside the exact same baseline — + // this is what a live runtime plugin contributing its own auth surface + // would look like from register-auth-routes.ts's perspective. + const connected = setup( + [...baselineOauth], + [...baselineApiKey, { id: "hermes", name: "Hermes" }], + ); + const connectedResponse = await callStatus(connected.handler); + + const disconnectedIds = disconnectedResponse.providers.map((p) => p.id); + const connectedIds = connectedResponse.providers.map((p) => p.id); + + // Every provider present without Hermes must still be present with Hermes. + for (const id of disconnectedIds) { + expect(connectedIds).toContain(id); + } + expect(connectedIds.length).toBeGreaterThanOrEqual(disconnectedIds.length); + }); + + it("preserves all baseline oauth + api-key providers across both states (empty vs populated Hermes entry)", async () => { + const oauth = [{ id: "anthropic-subscription", name: "Anthropic" }]; + const apiKeyNoHermes = [{ id: "openai", name: "OpenAI" }, { id: "google", name: "Google" }]; + const apiKeyWithHermes = [...apiKeyNoHermes, { id: "hermes", name: "Hermes" }]; + + const withoutHermes = await callStatus(setup(oauth, apiKeyNoHermes).handler); + const withHermes = await callStatus(setup(oauth, apiKeyWithHermes).handler); + + for (const id of ["anthropic-subscription", "openai", "google"]) { + expect(withoutHermes.providers.some((p) => p.id === id)).toBe(true); + expect(withHermes.providers.some((p) => p.id === id)).toBe(true); + } + }); +}); diff --git a/packages/dashboard/src/__tests__/register-model-routes-hermes-additive.test.ts b/packages/dashboard/src/__tests__/register-model-routes-hermes-additive.test.ts new file mode 100644 index 0000000000..7be0cd191c --- /dev/null +++ b/packages/dashboard/src/__tests__/register-model-routes-hermes-additive.test.ts @@ -0,0 +1,152 @@ +/* +FNXC:ModelCatalog 2026-07-07-08:10: +FN-7630 (GitHub #1931) regression coverage: a connected/active Hermes Runtime +plugin must never narrow /api/models' effective provider/model set. This +suite reproduces the reported symptom \u2014 a persisted customProviders entry +plus a "connected" Hermes runtime (simulated by Hermes-labeled entries +appearing in modelRegistry.getAvailable(), which is what a live runtime +plugin contributing models would look like) \u2014 and asserts the custom +provider's registry key stays in configuredProviders and its models stay in +the /api/models response, across empty/single/multiple customProviders data +states. +*/ +import { describe, expect, it, vi } from "vitest"; +import type { Router } from "express"; +import { registerModelRoutes } from "../routes/register-model-routes.js"; +import { customProviderRegistryKey } from "@fusion/core"; +import type { CustomProvider } from "@fusion/core"; + +interface SetupOptions { + customProviders: CustomProvider[]; + /** When true, simulate a connected/active Hermes runtime contributing its own models to the registry. */ + hermesConnected: boolean; +} + +function setup({ customProviders, hermesConnected }: SetupOptions) { + const getHandlers = new Map void }) => Promise>(); + const router = { + get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { + getHandlers.set(path, handler); + }), + } as unknown as Router; + + const store = { + getGlobalSettingsStore: () => ({ + getSettings: vi.fn().mockResolvedValue({ customProviders }), + }), + getSettingsFast: vi.fn().mockResolvedValue({}), + }; + + const runtimeLogger = { + child: vi.fn(() => ({ warn: vi.fn() })), + }; + + const customModels = customProviders.flatMap((provider) => + (provider.models ?? []).map((model) => ({ + provider: customProviderRegistryKey(provider, customProviders), + id: model.id, + name: model.name, + reasoning: false, + contextWindow: 0, + })), + ); + + // A connected Hermes runtime is simulated by the underlying model registry + // surfacing Hermes-provider entries alongside everything else — exactly how + // a live runtime plugin contributing models would present itself. Item 1 + // (additively surfacing these in the picker) is deferred; this test only + // proves their mere presence does not suppress unrelated entries. + const hermesModels = hermesConnected + ? [{ provider: "hermes", id: "hermes/default", name: "Hermes Default", reasoning: false, contextWindow: 0 }] + : []; + + const availableModels = [ + { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, + ...customModels, + ...hermesModels, + ]; + + const modelRegistry = { + refresh: vi.fn(), + getAvailable: vi.fn(() => availableModels), + }; + + registerModelRoutes({ + router, + store: store as never, + runtimeLogger: runtimeLogger as never, + options: { modelRegistry } as never, + } as never); + + return { handler: getHandlers.get("/models")!, modelRegistry }; +} + +async function callModels(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { + const json = vi.fn(); + await handler({}, { json }); + return json.mock.calls[0][0] as { models: Array<{ provider: string; id: string }> }; +} + +describe("FN-7630: Hermes runtime additive — /api/models", () => { + it("keeps a single custom provider's models present whether or not Hermes is connected", async () => { + const customProviders: CustomProvider[] = [ + { id: "cp-1", name: "My Provider", apiType: "openai-compatible", baseUrl: "https://example.com", models: [{ id: "custom-model-1", name: "Custom Model 1" }] }, + ]; + const key = customProviderRegistryKey(customProviders[0]!, customProviders); + + const disconnected = await callModels(setup({ customProviders, hermesConnected: false }).handler); + const connected = await callModels(setup({ customProviders, hermesConnected: true }).handler); + + for (const response of [disconnected, connected]) { + expect(response.models.some((m) => m.provider === key && m.id === "custom-model-1")).toBe(true); + } + }); + + it("keeps multiple custom providers' models present when Hermes is connected", async () => { + const customProviders: CustomProvider[] = [ + { id: "cp-1", name: "Provider One", apiType: "openai-compatible", baseUrl: "https://a.example.com", models: [{ id: "model-a", name: "Model A" }] }, + { id: "cp-2", name: "Provider Two", apiType: "anthropic-compatible", baseUrl: "https://b.example.com", models: [{ id: "model-b", name: "Model B" }] }, + ]; + const keyOne = customProviderRegistryKey(customProviders[0]!, customProviders); + const keyTwo = customProviderRegistryKey(customProviders[1]!, customProviders); + + const { handler } = setup({ customProviders, hermesConnected: true }); + const response = await callModels(handler); + + expect(response.models.some((m) => m.provider === keyOne && m.id === "model-a")).toBe(true); + expect(response.models.some((m) => m.provider === keyTwo && m.id === "model-b")).toBe(true); + }); + + it("does not shrink the effective model set when Hermes connects, with no customProviders configured", async () => { + const disconnected = await callModels(setup({ customProviders: [], hermesConnected: false }).handler); + const connected = await callModels(setup({ customProviders: [], hermesConnected: true }).handler); + + // Neither state has any configured auth/customProviders, so the effective + // (filtered) model set is empty both ways — connecting Hermes must not + // change that baseline (i.e. it must not remove entries that would + // otherwise be configured). This proves the filter step itself carries no + // Hermes-specific branch that could shrink an otherwise-configured set. + expect(connected.models.length).toBeGreaterThanOrEqual(disconnected.models.length); + }); + + it("never surfaces unconfigured Hermes-provider entries as a side effect (item 1 deferred, not silently regressed)", async () => { + const { handler } = setup({ customProviders: [], hermesConnected: true }); + const response = await callModels(handler); + // Item 1 (additive Hermes model surfacing) is explicitly deferred per the + // task docs; this asserts the deferral is a no-op today, not a silent + // failure that could later be confused with active suppression. + expect(response.models.some((m) => m.provider === "hermes")).toBe(false); + }); + + it("a custom provider whose registry key collides in name-shape with a Hermes-derived id still surfaces its models", async () => { + const customProviders: CustomProvider[] = [ + { id: "cp-hermes-like", name: "hermes", apiType: "openai-compatible", baseUrl: "https://c.example.com", models: [{ id: "collide-model", name: "Collide Model" }] }, + ]; + const key = customProviderRegistryKey(customProviders[0]!, customProviders); + + const { handler } = setup({ customProviders, hermesConnected: true }); + const response = await callModels(handler); + + expect(response.models.some((m) => m.provider === key && m.id === "collide-model")).toBe(true); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts b/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts new file mode 100644 index 0000000000..072adcd4c6 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/custom-provider-routes-hermes-additive.test.ts @@ -0,0 +1,236 @@ +// @vitest-environment node + +/* +FNXC:ProviderAuth 2026-07-07-08:30: +FN-7630 (GitHub #1931) symptom verification: reproduces the exact reported +condition — a persisted customProviders entry (with a model) AND the Hermes +Runtime plugin loaded/connected — and asserts the persisted customProviders +list is byte-for-byte unchanged and never deactivated by the Hermes plugin's +lifecycle hooks. This closes the loop with register-model-routes-hermes- +additive.test.ts (model-picker surface) and register-auth-routes-hermes- +additive.test.ts (auth surface). +*/ + +import express from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskStore, GlobalSettings, CustomProvider } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { request as performRequest } from "../../test-request.js"; + +const { mockInvalidateAllGlobalSettingsCaches } = vi.hoisted(() => ({ + mockInvalidateAllGlobalSettingsCaches: vi.fn(), +})); +vi.mock("../../project-store-resolver.js", async () => { + const actual = await vi.importActual("../../project-store-resolver.js"); + return { + ...actual, + invalidateAllGlobalSettingsCaches: mockInvalidateAllGlobalSettingsCaches, + }; +}); + +// Mock the Hermes plugin's own CLI/skill-install seams so its real onLoad/ +// onUnload hooks can run without spawning a real subprocess or touching disk. +const { mockResolveCli, mockInstallFusionSkill } = vi.hoisted(() => ({ + mockResolveCli: vi.fn().mockReturnValue({ + binaryPath: "hermes", + model: undefined, + provider: undefined, + maxTurns: 12, + yolo: false, + cliTimeoutMs: 300_000, + profile: undefined, + }), + mockInstallFusionSkill: vi.fn().mockReturnValue({ + outcome: "installed", + sourceDir: "/tmp/source", + targetDir: "/tmp/target", + }), +})); + +vi.mock("@fusion-plugin-examples/hermes-runtime/dist/cli-spawn.js", async () => { + const actual = await vi.importActual>("@fusion-plugin-examples/hermes-runtime/dist/cli-spawn.js"); + return { ...actual, resolveCliSettings: mockResolveCli }; +}); +vi.mock("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js", async () => { + const actual = await vi.importActual>("@fusion-plugin-examples/hermes-runtime/dist/fusion-skill-install.js"); + return { ...actual, installFusionSkillIntoHermesHome: mockInstallFusionSkill }; +}); + +function createMockGlobalSettingsStore(settings: GlobalSettings) { + return { + getSettings: vi.fn(async () => settings), + updateSettings: vi.fn(), + getSettingsPath: vi.fn(), + init: vi.fn(), + invalidateCache: vi.fn(), + }; +} + +function createMockStore(settings: GlobalSettings, onUpdate: (patch: Partial) => void): TaskStore { + const globalSettingsStore = createMockGlobalSettingsStore(settings); + return { + getTask: vi.fn(), + listTasks: vi.fn().mockResolvedValue([]), + searchTasks: vi.fn().mockResolvedValue([]), + createTask: vi.fn(), + moveTask: vi.fn(), + updateTask: vi.fn(), + deleteTask: vi.fn(), + mergeTask: vi.fn(), + archiveTask: vi.fn(), + unarchiveTask: vi.fn(), + getSettings: vi.fn().mockResolvedValue({}), + getSettingsFast: vi.fn().mockResolvedValue({}), + updateSettings: vi.fn(), + updateGlobalSettings: vi.fn(async (patch: Partial) => { + onUpdate(patch); + Object.assign(settings, patch); + return settings; + }), + getSettingsByScope: vi.fn().mockResolvedValue({ global: settings, project: {} }), + getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: settings, project: {} }), + getGlobalSettingsStore: vi.fn(() => globalSettingsStore), + logEntry: vi.fn(), + getAgentLogs: vi.fn().mockResolvedValue([]), + getAgentLogCount: vi.fn().mockResolvedValue(0), + getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]), + addSteeringComment: vi.fn(), + addTaskComment: vi.fn(), + updateTaskComment: vi.fn(), + deleteTaskComment: vi.fn(), + getTaskDocuments: vi.fn().mockResolvedValue([]), + getTaskDocument: vi.fn().mockResolvedValue(null), + getTaskDocumentRevisions: vi.fn().mockResolvedValue([]), + getAllDocuments: vi.fn().mockResolvedValue([]), + upsertTaskDocument: vi.fn(), + deleteTaskDocument: vi.fn(), + updatePrInfo: vi.fn(), + updateIssueInfo: vi.fn(), + getRootDir: vi.fn().mockReturnValue("/fake/root"), + getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"), + getDatabase: vi.fn(), + listWorkflowSteps: vi.fn().mockResolvedValue([]), + createWorkflowStep: vi.fn(), + getWorkflowStep: vi.fn(), + updateWorkflowStep: vi.fn(), + deleteWorkflowStep: vi.fn(), + getMissionStore: vi.fn(), + } as unknown as TaskStore; +} + +async function REQUEST( + app: express.Express, + method: string, + path: string, + body?: unknown, +): Promise<{ status: number; body: unknown }> { + const payload = body === undefined ? undefined : JSON.stringify(body); + const res = await performRequest( + app, + method, + path, + payload, + body === undefined ? undefined : { "Content-Type": "application/json" }, + ); + return { status: res.status, body: res.body }; +} + +function createApp(settings: GlobalSettings, onUpdate: (patch: Partial) => void = () => undefined) { + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(createMockStore(settings, onUpdate))); + return app; +} + +describe("FN-7630 symptom verification: customProviders + Hermes runtime connected", () => { + beforeEach(() => { + mockInvalidateAllGlobalSettingsCaches.mockReset(); + vi.unstubAllGlobals(); + }); + + it("leaves the persisted customProviders list byte-identical across the Hermes plugin's onLoad/onUnload lifecycle", async () => { + const persistedProvider: CustomProvider = { + id: "cp-symptom-1", + name: "Symptom Provider", + apiType: "openai-compatible", + baseUrl: "https://example.com", + apiKey: "sk-test-1234567890", + models: [{ id: "symptom-model-1", name: "Symptom Model 1" }], + }; + const settings: GlobalSettings = { customProviders: [persistedProvider] }; + const app = createApp(settings); + + const before = await REQUEST(app, "GET", "/api/custom-providers"); + expect(before.status).toBe(200); + const beforeBody = before.body as CustomProvider[]; + expect(beforeBody).toHaveLength(1); + expect(beforeBody[0]?.models).toEqual([{ id: "symptom-model-1", name: "Symptom Model 1" }]); + + // Connect (load) then disconnect (unload) the real Hermes plugin around + // the CRUD call — reproducing "activate/disconnect the Hermes runtime". + const hermesPlugin = (await import("@fusion-plugin-examples/hermes-runtime")).default; + const ctx = { + pluginId: "fusion-plugin-hermes-runtime", + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { getTask: vi.fn() }, + }; + await hermesPlugin.hooks!.onLoad!(ctx as never); + + const during = await REQUEST(app, "GET", "/api/custom-providers"); + expect(during.status).toBe(200); + expect(during.body).toEqual(beforeBody); + // The persisted settings object itself (source of truth) must be untouched. + expect(settings.customProviders).toEqual([persistedProvider]); + + await hermesPlugin.hooks!.onUnload!(ctx as never); + + const after = await REQUEST(app, "GET", "/api/custom-providers"); + expect(after.status).toBe(200); + expect(after.body).toEqual(beforeBody); + expect(settings.customProviders).toEqual([persistedProvider]); + }); + + it("leaves an empty customProviders list unaffected by a connected Hermes runtime", async () => { + const settings: GlobalSettings = { customProviders: [] }; + const app = createApp(settings); + + const hermesPlugin = (await import("@fusion-plugin-examples/hermes-runtime")).default; + await hermesPlugin.hooks!.onLoad!({ + pluginId: "fusion-plugin-hermes-runtime", + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { getTask: vi.fn() }, + } as never); + + const res = await REQUEST(app, "GET", "/api/custom-providers"); + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + expect(settings.customProviders).toEqual([]); + }); + + it("leaves multiple customProviders unaffected by a connected Hermes runtime", async () => { + const providers: CustomProvider[] = [ + { id: "cp-a", name: "Provider A", apiType: "openai-compatible", baseUrl: "https://a.example.com", models: [{ id: "model-a", name: "Model A" }] }, + { id: "cp-b", name: "Provider B", apiType: "google-generative-ai", baseUrl: "https://b.example.com", models: [{ id: "model-b", name: "Model B" }] }, + ]; + const settings: GlobalSettings = { customProviders: providers }; + const app = createApp(settings); + + const hermesPlugin = (await import("@fusion-plugin-examples/hermes-runtime")).default; + await hermesPlugin.hooks!.onLoad!({ + pluginId: "fusion-plugin-hermes-runtime", + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { getTask: vi.fn() }, + } as never); + + const res = await REQUEST(app, "GET", "/api/custom-providers"); + expect(res.status).toBe(200); + expect((res.body as CustomProvider[])).toHaveLength(2); + expect(settings.customProviders).toEqual(providers); + }); +}); diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 90eb87de49..a7bf2ea747 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -261,6 +261,18 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { // have set up in Fusion. We restrict to providers with credentials // in Fusion's own auth stores (primary + legacy .pi + models.json), // plus any providers enabled via settings toggles (Claude CLI, etc.). + /* + FNXC:ModelCatalog 2026-07-07-08:00: + FN-7630 (GitHub #1931): the Hermes Runtime plugin must be strictly additive + — connecting/activating or disconnecting it must never narrow this + configuredProviders allow-set. This block only ever ADDS provider ids + (auth-storage-derived, CLI-toggle-derived, and customProviders-derived); it + never removes an entry based on any runtime-plugin connection state, and no + Hermes-specific branch exists here by design. customProviders' registry keys + are added unconditionally (regardless of whether Hermes is loaded/connected) + so a connected Hermes runtime can never deactivate independently-configured + custom Fusion providers/models. See register-model-routes-hermes-additive.test.ts. + */ const configuredProviders = await getConfiguredProviderNames(options?.authStorage); if (useClaudeCli) configuredProviders.add("pi-claude-cli"); if (useDroidCli) configuredProviders.add("droid-cli"); diff --git a/plugins/fusion-plugin-hermes-runtime/src/index.ts b/plugins/fusion-plugin-hermes-runtime/src/index.ts index 9018f93712..3cae621004 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/index.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/index.ts @@ -38,6 +38,18 @@ const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => { // ── Plugin Definition ───────────────────────────────────────────────────────── +/* +FNXC:ModelCatalog 2026-07-07-08:00: +FN-7630 (GitHub #1931): connecting/activating this plugin must be strictly +additive to Fusion's global provider/model/auth catalogs — it must never +suppress, deactivate, or hide independently-configured custom providers, +models, or auth options. `onLoad`/`onUnload` below intentionally receive no +reference to AuthStorage/ModelRegistry/global settings (see PluginContext in +@fusion/plugin-sdk) so this plugin is structurally incapable of mutating +those stores; it only resolves its own CLI settings, installs its own skill, +and logs/emits its own lifecycle events. Do not widen PluginContext access +from this plugin without re-auditing this invariant. +*/ const plugin: FusionPlugin = definePlugin({ manifest: { id: "fusion-plugin-hermes-runtime",