mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
This commit is contained in:
1 parent
c58ccbfc3f
commit
504710b065
43 files changed
+568
-262
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSystemPrompt } from "../../ai/context.js";
|
||||
import { buildSystemPrompt } from "../../../../../plugins/ai/backend/context.js";
|
||||
|
||||
const BASE = { hostCount: 3, allowReadOnlyCommands: false };
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
evaluateEgress,
|
||||
isPrivateDestination,
|
||||
parseAllowlist,
|
||||
} from "../../ai/egress.js";
|
||||
} from "../../../../../plugins/ai/backend/egress.js";
|
||||
|
||||
/**
|
||||
* The rule that lets a self-hosted Ollama work without turning the backend
|
||||
@@ -1,14 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../ai/providers/types.js";
|
||||
import type { ChatChunk } from "../../../../../plugins/ai/backend/providers/types.js";
|
||||
|
||||
const streamChat = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
vi.mock("../../ai/providers/registry.js", () => ({
|
||||
vi.mock("../../../../../plugins/ai/backend/providers/registry.js", () => ({
|
||||
getAdapter: () => ({ streamChat, listModels: async () => [] }),
|
||||
}));
|
||||
|
||||
vi.mock("../../ai/tools/catalog.js", () => ({
|
||||
vi.mock("../../../../../plugins/ai/backend/tools/catalog.js", () => ({
|
||||
getTool: (name: string) =>
|
||||
name === "list_hosts"
|
||||
? {
|
||||
@@ -24,7 +24,8 @@ vi.mock("../../ai/tools/catalog.js", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
const { runAgent } = await import("../../ai/engine.js");
|
||||
const { runAgent } =
|
||||
await import("../../../../../plugins/ai/backend/engine.js");
|
||||
|
||||
function chunks(...values: ChatChunk[]) {
|
||||
return (async function* () {
|
||||
@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const settingsRepository = { getBoolean: vi.fn() };
|
||||
const userPreferenceRepository = { findByUserId: vi.fn() };
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => settingsRepository,
|
||||
createCurrentUserPreferenceRepository: () => userPreferenceRepository,
|
||||
}));
|
||||
|
||||
const { isAiGloballyEnabled, resolveAiAccess } =
|
||||
await import("../../ai/gating.js");
|
||||
await import("../../../../../plugins/ai/backend/gating.js");
|
||||
|
||||
describe("AI gating", () => {
|
||||
beforeEach(() => {
|
||||
+7
-4
@@ -7,17 +7,20 @@ const readPrivateAllowlist = vi.fn();
|
||||
const evaluateEgress = vi.fn();
|
||||
const globalFetch = vi.fn();
|
||||
|
||||
vi.mock("../../utils/proxy-agent.js", () => ({
|
||||
vi.mock("../../../../utils/proxy-agent.js", () => ({
|
||||
fetchWithProxy,
|
||||
getFetchDispatcher,
|
||||
}));
|
||||
vi.mock("../../utils/safe-outbound-fetch.js", () => ({ safeOutboundFetch }));
|
||||
vi.mock("../../ai/egress.js", () => ({
|
||||
vi.mock("../../../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch,
|
||||
}));
|
||||
vi.mock("../../../../../../plugins/ai/backend/egress.js", () => ({
|
||||
readPrivateAllowlist,
|
||||
evaluateEgress,
|
||||
}));
|
||||
|
||||
const { providerFetch } = await import("../../ai/providers/http.js");
|
||||
const { providerFetch } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/http.js");
|
||||
|
||||
describe("providerFetch", () => {
|
||||
beforeEach(() => {
|
||||
+10
-7
@@ -1,18 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatChunk } from "../../ai/providers/types.js";
|
||||
import type { ChatChunk } from "../../../../../../plugins/ai/backend/providers/types.js";
|
||||
|
||||
const providerFetch = vi.fn();
|
||||
|
||||
vi.mock("../../ai/providers/http.js", async () => {
|
||||
vi.mock("../../../../../../plugins/ai/backend/providers/http.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../ai/providers/http.js")
|
||||
>("../../ai/providers/http.js");
|
||||
typeof import("../../../../../../plugins/ai/backend/providers/http.js")
|
||||
>("../../../../../../plugins/ai/backend/providers/http.js");
|
||||
return { ...actual, providerFetch };
|
||||
});
|
||||
|
||||
const { openAiAdapter } = await import("../../ai/providers/openai.js");
|
||||
const { ollamaAdapter } = await import("../../ai/providers/ollama.js");
|
||||
const { geminiAdapter } = await import("../../ai/providers/gemini.js");
|
||||
const { openAiAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/openai.js");
|
||||
const { ollamaAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/ollama.js");
|
||||
const { geminiAdapter } =
|
||||
await import("../../../../../../plugins/ai/backend/providers/gemini.js");
|
||||
|
||||
/** Builds a Response whose body streams the given text chunks. */
|
||||
function streamingResponse(lines: string[]): Response {
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED, redact, redactString } from "../../ai/redaction.js";
|
||||
import {
|
||||
REDACTED,
|
||||
redact,
|
||||
redactString,
|
||||
} from "../../../../../plugins/ai/backend/redaction.js";
|
||||
|
||||
describe("redact", () => {
|
||||
it("drops secret-named fields at any depth", () => {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isReadOnlyCommand } from "../../ai/tools/command-allowlist.js";
|
||||
import { isReadOnlyCommand } from "../../../../../../plugins/ai/backend/tools/command-allowlist.js";
|
||||
|
||||
/**
|
||||
* These commands can run without a per-command approval click, so the parser
|
||||
+7
-6
@@ -16,7 +16,7 @@ const fleetRepository = { create: vi.fn(), addMember: vi.fn() };
|
||||
const alertRepository = { createAlertRule: vi.fn() };
|
||||
const automationRepository = { create: vi.fn() };
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
vi.mock("../../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostRepository: () => hostRepository,
|
||||
createCurrentSnippetRepository: () => snippetRepository,
|
||||
createCurrentFleetRepository: () => fleetRepository,
|
||||
@@ -25,19 +25,19 @@ vi.mock("../../database/repositories/factory.js", () => ({
|
||||
}));
|
||||
|
||||
const resolveHostById = vi.fn();
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
vi.mock("../../../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: (...args: unknown[]) => resolveHostById(...args),
|
||||
}));
|
||||
|
||||
const execCommand = vi.fn();
|
||||
vi.mock("../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
vi.mock("../../../../hosts/metrics-shared/common-utils.js", () => ({
|
||||
execCommand: (...args: unknown[]) => execCommand(...args),
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-client-factory.js", () => ({
|
||||
vi.mock("../../../../hosts/ssh-client-factory.js", () => ({
|
||||
createFleetSshFactory: () => () => ({}),
|
||||
getFleetPoolKey: () => "pool",
|
||||
}));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
vi.mock("../../../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
_key: string,
|
||||
_factory: unknown,
|
||||
@@ -45,7 +45,8 @@ vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
) => run({}),
|
||||
}));
|
||||
|
||||
const { applyProposal } = await import("../../ai/tools/executor.js");
|
||||
const { applyProposal } =
|
||||
await import("../../../../../../plugins/ai/backend/tools/executor.js");
|
||||
|
||||
describe("applyProposal", () => {
|
||||
beforeEach(() => {
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
getTool,
|
||||
listToolNames,
|
||||
toolDefinitions,
|
||||
} from "../../ai/tools/catalog.js";
|
||||
} from "../../../../../../plugins/ai/backend/tools/catalog.js";
|
||||
|
||||
/**
|
||||
* The security regression test for the whole feature.
|
||||
@@ -39,18 +39,20 @@ function manifest(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
|
||||
describe("first-party allowlist", () => {
|
||||
it("contains only ssh-terminal, docker and host-metrics", () => {
|
||||
it("contains only ssh-terminal, docker, host-metrics and ai", () => {
|
||||
expect([...FIRST_PARTY_PLUGIN_IDS].sort()).toEqual([
|
||||
"ai",
|
||||
"docker",
|
||||
"host-metrics",
|
||||
"ssh-terminal",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognises ssh-terminal, docker and host-metrics and nothing else", () => {
|
||||
it("recognises ssh-terminal, docker, host-metrics and ai and nothing else", () => {
|
||||
expect(isFirstParty("ssh-terminal")).toBe(true);
|
||||
expect(isFirstParty("docker")).toBe(true);
|
||||
expect(isFirstParty("host-metrics")).toBe(true);
|
||||
expect(isFirstParty("ai")).toBe(true);
|
||||
expect(isFirstParty("ssh-terminal-pro")).toBe(false);
|
||||
expect(isFirstParty("community-plugin")).toBe(false);
|
||||
expect(isFirstParty("")).toBe(false);
|
||||
@@ -66,12 +68,14 @@ describe("runsInProcess", () => {
|
||||
expect(runsInProcess("host-metrics", [TRANSPORT_OWNER_CAPABILITY])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(runsInProcess("ai", [TRANSPORT_OWNER_CAPABILITY])).toBe(true);
|
||||
|
||||
// On the list but not asking for it: stays in a worker, so the manifest
|
||||
// remains an honest description of what the plugin does.
|
||||
expect(runsInProcess("ssh-terminal", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("docker", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("host-metrics", ["hosts.read"])).toBe(false);
|
||||
expect(runsInProcess("ai", ["hosts.read"])).toBe(false);
|
||||
|
||||
// Asking for it but not on the list.
|
||||
expect(
|
||||
@@ -139,6 +143,18 @@ describe("manifest gate on the reserved capability", () => {
|
||||
expect(parsed?.id).toBe("host-metrics");
|
||||
});
|
||||
|
||||
it("allows it for ai", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({
|
||||
id: "ai",
|
||||
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(parsed?.id).toBe("ai");
|
||||
});
|
||||
|
||||
it("still accepts an ordinary manifest that never mentions it", () => {
|
||||
const { manifest: parsed, errors } = parseManifest(
|
||||
manifest({ permissions: ["hosts.read"] }),
|
||||
|
||||
Reference in new issue
Block a user