FN-7630: harden Hermes Runtime as additive-only to provider/model/auth catalogs

Confirms and locks in that the Hermes Runtime plugin cannot suppress independently-configured custom providers, models, or auth options, closing out GitHub #1931's remaining audit items.

- Add FNXC documentation comments to register-model-routes.ts and the Hermes Runtime plugin explaining why the plugin structurally cannot mutate AuthStorage/ModelRegistry (no reference in PluginContext) and why configuredProviders only ever grows.
- Add regression coverage proving a connected Hermes runtime never narrows the model picker (/api/models), custom-provider CRUD routes, or auth-status surfaces.
- Add changeset documenting the fix and remaining follow-up items (FN-7625, FN-7636).

Files changed:
 .changeset/fn-7630-hermes-runtime-additive.md      |   7 +
 .../register-auth-routes-hermes-additive.test.ts   | 108 ++++++++++
 .../register-model-routes-hermes-additive.test.ts  | 152 +++++++++++++
 .../custom-provider-routes-hermes-additive.test.ts | 236 +++++++++++++++++++++
 .../dashboard/src/routes/register-model-routes.ts  |  12 ++
 plugins/fusion-plugin-hermes-runtime/src/index.ts  |  12 ++
 6 files changed, 527 insertions(+)

Fusion-Task-Id: FN-7630
Fusion-Task-Lineage: 651cb242-cabf-487b-bf19-21c51b2e91d3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-07 08:28:12 -07:00
parent fe5a595984
commit 6bf0090a47
6 changed files with 527 additions and 0 deletions

View File

@@ -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.

View File

@@ -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<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const postHandlers = new Map<string, unknown>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
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<void>) {
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);
}
});
});

View File

@@ -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<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
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<void>) {
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);
});
});

View File

@@ -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<typeof import("../../project-store-resolver.js")>("../../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<Record<string, unknown>>("@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<Record<string, unknown>>("@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<GlobalSettings>) => 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<GlobalSettings>) => {
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<GlobalSettings>) => 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);
});
});

View File

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

View File

@@ -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",