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:
LukeGus committed 2026-09-21 15:20:05 -05:00
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,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(() => {
@@ -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 {
@@ -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,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
@@ -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(() => {
@@ -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.
+18 -2
View File
@@ -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"] }),