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
|
// have set up in Fusion. We restrict to providers with credentials
|
||||||
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
||||||
// plus any providers enabled via settings toggles (Claude CLI, etc.).
|
// 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);
|
const configuredProviders = await getConfiguredProviderNames(options?.authStorage);
|
||||||
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
|
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
|
||||||
if (useDroidCli) configuredProviders.add("droid-cli");
|
if (useDroidCli) configuredProviders.add("droid-cli");
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ const hermesRuntimeFactory: PluginRuntimeFactory = async (ctx) => {
|
|||||||
|
|
||||||
// ── Plugin Definition ─────────────────────────────────────────────────────────
|
// ── 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({
|
const plugin: FusionPlugin = definePlugin({
|
||||||
manifest: {
|
manifest: {
|
||||||
id: "fusion-plugin-hermes-runtime",
|
id: "fusion-plugin-hermes-runtime",
|
||||||
|
|||||||
Reference in New Issue
Block a user