From 504710b065466c98ee1e77263ca1edc6799475fb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 21 Sep 2026 15:20:05 -0500 Subject: [PATCH] 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. --- .../ai => plugins/ai/backend}/context.ts | 0 .../ai => plugins/ai/backend}/egress.ts | 4 +- .../ai => plugins/ai/backend}/engine.ts | 2 +- .../ai => plugins/ai/backend}/gating.ts | 4 +- plugins/ai/backend/index.mjs | 65 +++++ .../ai/backend}/providers/anthropic.ts | 0 .../ai/backend}/providers/gemini.ts | 0 .../ai/backend}/providers/http.ts | 4 +- .../ai/backend}/providers/ollama.ts | 0 .../ai/backend}/providers/openai.ts | 0 .../ai/backend}/providers/registry.ts | 0 .../ai/backend}/providers/types.ts | 0 .../ai => plugins/ai/backend}/redaction.ts | 0 .../index.ts => plugins/ai/backend/routes.ts | 253 +++++++++++++++++- .../ai/backend}/tools/catalog.ts | 0 .../ai/backend}/tools/command-allowlist.ts | 0 .../ai/backend}/tools/executor.ts | 12 +- .../ai/backend}/tools/propose-tools.ts | 0 .../ai/backend}/tools/read-tools.ts | 2 +- .../ai => plugins/ai/backend}/tools/types.ts | 0 plugins/ai/frontend/index.mjs | 38 +++ plugins/ai/manifest.json | 30 +++ src/backend/database/database.ts | 4 +- src/backend/database/routes/ai-dispatch.ts | 46 ++++ .../database/routes/user-settings-routes.ts | 201 -------------- src/backend/plugins/first-party.ts | 10 + src/backend/starter.ts | 3 + .../tests/{ => plugins}/ai/context.test.ts | 2 +- .../tests/{ => plugins}/ai/egress.test.ts | 2 +- .../tests/{ => plugins}/ai/engine.test.ts | 9 +- .../tests/{ => plugins}/ai/gating.test.ts | 4 +- .../ai/providers}/provider-http.test.ts | 11 +- .../ai/providers}/providers.test.ts | 17 +- .../tests/{ => plugins}/ai/redaction.test.ts | 6 +- .../ai/tools}/command-allowlist.test.ts | 2 +- .../ai/tools}/proposal-executor.test.ts | 13 +- .../ai/tools}/tool-catalog.test.ts | 2 +- src/backend/tests/plugins/first-party.test.ts | 20 +- src/ui/api/ai-api.ts | 9 +- src/ui/shell/pluginLoader.ts | 8 + src/ui/tests/features/ai/labels.test.ts | 2 +- src/ui/tests/shell/pluginLoader.test.ts | 42 +++ tsconfig.plugins.json | 3 +- 43 files changed, 568 insertions(+), 262 deletions(-) rename {src/backend/ai => plugins/ai/backend}/context.ts (100%) rename {src/backend/ai => plugins/ai/backend}/egress.ts (94%) rename {src/backend/ai => plugins/ai/backend}/engine.ts (98%) rename {src/backend/ai => plugins/ai/backend}/gating.ts (93%) create mode 100644 plugins/ai/backend/index.mjs rename {src/backend/ai => plugins/ai/backend}/providers/anthropic.ts (100%) rename {src/backend/ai => plugins/ai/backend}/providers/gemini.ts (100%) rename {src/backend/ai => plugins/ai/backend}/providers/http.ts (96%) rename {src/backend/ai => plugins/ai/backend}/providers/ollama.ts (100%) rename {src/backend/ai => plugins/ai/backend}/providers/openai.ts (100%) rename {src/backend/ai => plugins/ai/backend}/providers/registry.ts (100%) rename {src/backend/ai => plugins/ai/backend}/providers/types.ts (100%) rename {src/backend/ai => plugins/ai/backend}/redaction.ts (100%) rename src/backend/ai/index.ts => plugins/ai/backend/routes.ts (80%) rename {src/backend/ai => plugins/ai/backend}/tools/catalog.ts (100%) rename {src/backend/ai => plugins/ai/backend}/tools/command-allowlist.ts (100%) rename {src/backend/ai => plugins/ai/backend}/tools/executor.ts (95%) rename {src/backend/ai => plugins/ai/backend}/tools/propose-tools.ts (100%) rename {src/backend/ai => plugins/ai/backend}/tools/read-tools.ts (99%) rename {src/backend/ai => plugins/ai/backend}/tools/types.ts (100%) create mode 100644 plugins/ai/frontend/index.mjs create mode 100644 plugins/ai/manifest.json create mode 100644 src/backend/database/routes/ai-dispatch.ts rename src/backend/tests/{ => plugins}/ai/context.test.ts (95%) rename src/backend/tests/{ => plugins}/ai/egress.test.ts (98%) rename src/backend/tests/{ => plugins}/ai/engine.test.ts (94%) rename src/backend/tests/{ => plugins}/ai/gating.test.ts (96%) rename src/backend/tests/{ai => plugins/ai/providers}/provider-http.test.ts (79%) rename src/backend/tests/{ai => plugins/ai/providers}/providers.test.ts (93%) rename src/backend/tests/{ => plugins}/ai/redaction.test.ts (95%) rename src/backend/tests/{ai => plugins/ai/tools}/command-allowlist.test.ts (96%) rename src/backend/tests/{ai => plugins/ai/tools}/proposal-executor.test.ts (95%) rename src/backend/tests/{ai => plugins/ai/tools}/tool-catalog.test.ts (98%) diff --git a/src/backend/ai/context.ts b/plugins/ai/backend/context.ts similarity index 100% rename from src/backend/ai/context.ts rename to plugins/ai/backend/context.ts diff --git a/src/backend/ai/egress.ts b/plugins/ai/backend/egress.ts similarity index 94% rename from src/backend/ai/egress.ts rename to plugins/ai/backend/egress.ts index c2be8aeb3..fbdcb7d79 100644 --- a/src/backend/ai/egress.ts +++ b/plugins/ai/backend/egress.ts @@ -1,6 +1,6 @@ import { isIP } from "net"; -import { isBlockedAddress } from "../utils/safe-outbound-fetch.js"; -import { createCurrentSettingsRepository } from "../database/repositories/factory.js"; +import { isBlockedAddress } from "../../../src/backend/utils/safe-outbound-fetch.js"; +import { createCurrentSettingsRepository } from "../../../src/backend/database/repositories/factory.js"; /** * Where the assistant is allowed to send requests. diff --git a/src/backend/ai/engine.ts b/plugins/ai/backend/engine.ts similarity index 98% rename from src/backend/ai/engine.ts rename to plugins/ai/backend/engine.ts index 17aaed09a..4b184e08f 100644 --- a/src/backend/ai/engine.ts +++ b/plugins/ai/backend/engine.ts @@ -1,4 +1,4 @@ -import { getErrorMessage } from "../utils/error-message.js"; +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; import { getAdapter } from "./providers/registry.js"; import type { ChatMessage, diff --git a/src/backend/ai/gating.ts b/plugins/ai/backend/gating.ts similarity index 93% rename from src/backend/ai/gating.ts rename to plugins/ai/backend/gating.ts index 689363ab3..77ec27e8e 100644 --- a/src/backend/ai/gating.ts +++ b/plugins/ai/backend/gating.ts @@ -1,9 +1,9 @@ import type { NextFunction, Response } from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; +import type { AuthenticatedRequest } from "../../../src/types/index.js"; import { createCurrentSettingsRepository, createCurrentUserPreferenceRepository, -} from "../database/repositories/factory.js"; +} from "../../../src/backend/database/repositories/factory.js"; /** * Three gates, all checked on the server. diff --git a/plugins/ai/backend/index.mjs b/plugins/ai/backend/index.mjs new file mode 100644 index 000000000..54cc1ff02 --- /dev/null +++ b/plugins/ai/backend/index.mjs @@ -0,0 +1,65 @@ +/** + * AI Assistant - first-party, in-process plugin. + * + * This plugin runs on the main thread rather than in a worker for the same + * reason ssh-terminal, docker and host-metrics do (see + * src/backend/plugins/first-party.ts): it writes directly across hosts, + * snippets, fleets, alert rules and automations through their repositories, + * runs approved commands over the shared SSH pool via resolveHostById and + * withConnection, and streams its chat replies as server-sent events. None + * of that fits the worker ctx -- ctx.hosts is a 13-field read-only view with + * no write methods and no snippets/fleets/alerts/automations surface at all, + * and ctx.http.route replies with a single postMessage value rather than a + * stream. + * + * Unlike ssh-terminal/docker/host-metrics, this plugin does not own a live + * transport (no WebSocket server, no long-lived connection that must outlive + * the main server's route table), so it does not get its own port. Instead + * it registers its Express router with the always-mounted /ai dispatcher in + * src/backend/database/routes/ai-dispatch.ts, the same "register a router, + * a small dispatcher forwards to it" shape /plugin-api uses for worker + * plugins, just scoped to this one path so /ai/* keeps its existing routes + * instead of moving under /plugin-api/ai/. + * + * routes.ts is what used to be src/backend/ai/index.ts, moved here with its + * sibling modules (context.ts, egress.ts, engine.ts, gating.ts, redaction.ts, + * providers/, tools/) and rewritten from `export default router` into + * startAiService()/stopAiService(), called from activate()/deactivate() + * below. + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Loads a module by its path relative to this file's own directory. + * + * The plugin ships beside dist/backend, so in a built server the compiled + * .js for the moved AI files (routes.js, ...) sits right next to this .mjs, + * and core modules resolve via ../../../backend/backend/... from there + * (dist/plugins/ai/backend -> dist/backend/backend). Under vitest/dev nothing + * imports this plugin directly (the loader loads in-process plugins with a + * plain dynamic import, no tsx loader involved), so the only layout that + * needs to resolve here is the built one. + */ +async function loadRelative(relativePath) { + const here = path.dirname(fileURLToPath(import.meta.url)); + const target = path.join(here, `${relativePath}.js`); + return import(new URL(`file://${target.replace(/\\/g, "/")}`).href); +} + +let routesModule = null; + +export async function activate(ctx) { + routesModule = await loadRelative("routes"); + routesModule.startAiService(); + + ctx.log.info("AI assistant router registered at /ai"); +} + +export async function deactivate() { + if (routesModule) { + routesModule.stopAiService(); + routesModule = null; + } +} diff --git a/src/backend/ai/providers/anthropic.ts b/plugins/ai/backend/providers/anthropic.ts similarity index 100% rename from src/backend/ai/providers/anthropic.ts rename to plugins/ai/backend/providers/anthropic.ts diff --git a/src/backend/ai/providers/gemini.ts b/plugins/ai/backend/providers/gemini.ts similarity index 100% rename from src/backend/ai/providers/gemini.ts rename to plugins/ai/backend/providers/gemini.ts diff --git a/src/backend/ai/providers/http.ts b/plugins/ai/backend/providers/http.ts similarity index 96% rename from src/backend/ai/providers/http.ts rename to plugins/ai/backend/providers/http.ts index b3f6b4ebb..758813b76 100644 --- a/src/backend/ai/providers/http.ts +++ b/plugins/ai/backend/providers/http.ts @@ -1,5 +1,5 @@ -import { fetchWithProxy } from "../../utils/proxy-agent.js"; -import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js"; +import { fetchWithProxy } from "../../../../src/backend/utils/proxy-agent.js"; +import { safeOutboundFetch } from "../../../../src/backend/utils/safe-outbound-fetch.js"; import { evaluateEgress, readPrivateAllowlist } from "../egress.js"; import { AiProviderError } from "./types.js"; diff --git a/src/backend/ai/providers/ollama.ts b/plugins/ai/backend/providers/ollama.ts similarity index 100% rename from src/backend/ai/providers/ollama.ts rename to plugins/ai/backend/providers/ollama.ts diff --git a/src/backend/ai/providers/openai.ts b/plugins/ai/backend/providers/openai.ts similarity index 100% rename from src/backend/ai/providers/openai.ts rename to plugins/ai/backend/providers/openai.ts diff --git a/src/backend/ai/providers/registry.ts b/plugins/ai/backend/providers/registry.ts similarity index 100% rename from src/backend/ai/providers/registry.ts rename to plugins/ai/backend/providers/registry.ts diff --git a/src/backend/ai/providers/types.ts b/plugins/ai/backend/providers/types.ts similarity index 100% rename from src/backend/ai/providers/types.ts rename to plugins/ai/backend/providers/types.ts diff --git a/src/backend/ai/redaction.ts b/plugins/ai/backend/redaction.ts similarity index 100% rename from src/backend/ai/redaction.ts rename to plugins/ai/backend/redaction.ts diff --git a/src/backend/ai/index.ts b/plugins/ai/backend/routes.ts similarity index 80% rename from src/backend/ai/index.ts rename to plugins/ai/backend/routes.ts index 364ddc6fe..3c551b2de 100644 --- a/src/backend/ai/index.ts +++ b/plugins/ai/backend/routes.ts @@ -1,21 +1,30 @@ -import { getErrorMessage } from "../utils/error-message.js"; +import { getErrorMessage } from "../../../src/backend/utils/error-message.js"; import express from "express"; -import type { AuthenticatedRequest } from "../../types/index.js"; -import { PermissionManager } from "../utils/permission-manager.js"; -import { AuthManager } from "../utils/auth-manager.js"; -import { databaseLogger } from "../utils/logger.js"; +import { + registerAiRouter, + unregisterAiRouter, +} from "../../../src/backend/database/routes/ai-dispatch.js"; +import type { AuthenticatedRequest } from "../../../src/types/index.js"; +import { PermissionManager } from "../../../src/backend/utils/permission-manager.js"; +import { AuthManager } from "../../../src/backend/utils/auth-manager.js"; +import { databaseLogger } from "../../../src/backend/utils/logger.js"; import { getAuditUsername, getRequestMeta, logAudit, -} from "../utils/audit-logger.js"; +} from "../../../src/backend/utils/audit-logger.js"; import { createCurrentAiRepository, createCurrentHostRepository, -} from "../database/repositories/factory.js"; + createCurrentSettingsRepository, + createCurrentUserRepository, +} from "../../../src/backend/database/repositories/factory.js"; +import type { UserRecord } from "../../../src/backend/database/repositories/user-repository.js"; import { buildSystemPrompt } from "./context.js"; +import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "./egress.js"; import { runAgent } from "./engine.js"; import { + AI_GLOBAL_ENABLED_KEY, createAiGate, isAiGloballyEnabled, resolveAiAccess, @@ -47,6 +56,226 @@ function parseId(raw: unknown): number | null { return Number.isInteger(id) && id > 0 ? id : null; } +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + +/** + * @openapi + * /ai/enabled: + * get: + * summary: Get whether the AI assistant is enabled instance-wide + * tags: + * - AI + * responses: + * 200: + * description: AI enabled status. + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + */ +router.get("/enabled", authenticateJWT, async (_req, res) => { + try { + res.json({ + // Defaults to false so upgrading an install never turns the assistant + // on without an admin deciding to. + enabled: await createCurrentSettingsRepository().getBoolean( + AI_GLOBAL_ENABLED_KEY, + false, + ), + }); + } catch (err) { + databaseLogger.error("Failed to get AI enabled setting", err, { + operation: "ai_enabled_get_failed", + }); + res.status(500).json({ error: "Failed to get AI enabled setting" }); + } +}); + +/** + * @openapi + * /ai/enabled: + * patch: + * summary: Update the instance-wide AI assistant setting (admin only) + * description: Turning this off hides and blocks the assistant for every user, whatever their own preference says. + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * enabled: + * type: boolean + * responses: + * 200: + * description: Setting updated. + * 403: + * description: Not authorized. + */ +router.patch("/enabled", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + const { enabled } = req.body ?? {}; + if (typeof enabled !== "boolean") { + return res.status(400).json({ error: "enabled must be a boolean" }); + } + await createCurrentSettingsRepository().set( + AI_GLOBAL_ENABLED_KEY, + enabled ? "true" : "false", + ); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: userId as string, + username: actor.username ?? (userId as string), + action: "update_ai_enabled", + resourceType: "setting", + details: JSON.stringify({ enabled }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ enabled }); + } catch (err) { + databaseLogger.error("Failed to update AI enabled setting", err, { + operation: "ai_enabled_update_failed", + }); + res.status(500).json({ error: "Failed to update AI enabled setting" }); + } +}); + +/** + * @openapi + * /ai/private-endpoints: + * get: + * summary: Get the allowlist of private AI endpoint hosts + * tags: + * - AI + * responses: + * 200: + * description: Allowed hosts. + */ +router.get("/private-endpoints", authenticateJWT, async (_req, res) => { + try { + const raw = await createCurrentSettingsRepository().get( + AI_PRIVATE_ALLOWLIST_KEY, + ); + res.json({ hosts: parseAllowlist(raw) }); + } catch (err) { + databaseLogger.error("Failed to get AI private endpoint allowlist", err, { + operation: "ai_private_endpoints_get_failed", + }); + res.status(500).json({ error: "Failed to get the allowlist" }); + } +}); + +/** + * @openapi + * /ai/private-endpoints: + * patch: + * summary: Replace the allowlist of private AI endpoint hosts (admin only) + * description: > + * Providers on private or loopback addresses, such as a self-hosted + * Ollama, are refused unless their host appears here. Without this an + * ordinary user could point a provider at an internal service and use + * the server as a probe of its own network. + * tags: + * - AI + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hosts: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Allowlist updated. + * 403: + * description: Not authorized. + */ +router.patch("/private-endpoints", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + try { + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + + const { hosts } = req.body ?? {}; + if (!Array.isArray(hosts)) { + return res.status(400).json({ error: "hosts must be an array" }); + } + if (hosts.length > 50) { + return res.status(400).json({ error: "At most 50 hosts are allowed" }); + } + + const cleaned: string[] = []; + for (const entry of hosts) { + if (typeof entry !== "string") { + return res.status(400).json({ error: "Each host must be a string" }); + } + const host = entry.trim().toLowerCase(); + if (!host) continue; + // A bare host, not a URL: no scheme, path, port or whitespace. + if (!/^[a-z0-9._:-]+$/.test(host)) { + return res + .status(400) + .json({ error: `${entry} is not a valid hostname` }); + } + if (!cleaned.includes(host)) cleaned.push(host); + } + + await createCurrentSettingsRepository().set( + AI_PRIVATE_ALLOWLIST_KEY, + JSON.stringify(cleaned), + ); + + const { ipAddress, userAgent } = getRequestMeta(req); + await logAudit({ + userId: userId as string, + username: actor.username ?? (userId as string), + action: "update_ai_private_endpoints", + resourceType: "setting", + details: JSON.stringify({ hosts: cleaned }), + ipAddress, + userAgent, + success: true, + }); + + res.json({ hosts: cleaned }); + } catch (err) { + databaseLogger.error( + "Failed to update AI private endpoint allowlist", + err, + { + operation: "ai_private_endpoints_update_failed", + }, + ); + res.status(500).json({ error: "Failed to update the allowlist" }); + } +}); + /** * @openapi * /ai/status: @@ -1110,4 +1339,12 @@ router.post( }, ); -export default router; +/** Called from activate(). Mounts this router at /ai via the shared dispatcher. */ +export function startAiService(): void { + registerAiRouter(router); +} + +/** Called from deactivate(). /ai/* falls back to 404 until reactivated. */ +export function stopAiService(): void { + unregisterAiRouter(); +} diff --git a/src/backend/ai/tools/catalog.ts b/plugins/ai/backend/tools/catalog.ts similarity index 100% rename from src/backend/ai/tools/catalog.ts rename to plugins/ai/backend/tools/catalog.ts diff --git a/src/backend/ai/tools/command-allowlist.ts b/plugins/ai/backend/tools/command-allowlist.ts similarity index 100% rename from src/backend/ai/tools/command-allowlist.ts rename to plugins/ai/backend/tools/command-allowlist.ts diff --git a/src/backend/ai/tools/executor.ts b/plugins/ai/backend/tools/executor.ts similarity index 95% rename from src/backend/ai/tools/executor.ts rename to plugins/ai/backend/tools/executor.ts index 01d768fe7..735391394 100644 --- a/src/backend/ai/tools/executor.ts +++ b/plugins/ai/backend/tools/executor.ts @@ -4,15 +4,15 @@ import { createCurrentFleetRepository, createCurrentHostRepository, createCurrentSnippetRepository, -} from "../../database/repositories/factory.js"; -import { validateDefinition } from "../../database/routes/automations.js"; -import { resolveHostById } from "../../hosts/host-resolver.js"; -import { execCommand } from "../../hosts/metrics-shared/common-utils.js"; +} from "../../../../src/backend/database/repositories/factory.js"; +import { validateDefinition } from "../../../../src/backend/database/routes/automations.js"; +import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; +import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js"; import { createFleetSshFactory, getFleetPoolKey, -} from "../../hosts/ssh-client-factory.js"; -import { withConnection } from "../../hosts/ssh-connection-pool.js"; +} from "../../../../src/backend/hosts/ssh-client-factory.js"; +import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js"; import { getTool } from "./catalog.js"; /** Approved commands get a bounded window rather than hanging the request. */ diff --git a/src/backend/ai/tools/propose-tools.ts b/plugins/ai/backend/tools/propose-tools.ts similarity index 100% rename from src/backend/ai/tools/propose-tools.ts rename to plugins/ai/backend/tools/propose-tools.ts diff --git a/src/backend/ai/tools/read-tools.ts b/plugins/ai/backend/tools/read-tools.ts similarity index 99% rename from src/backend/ai/tools/read-tools.ts rename to plugins/ai/backend/tools/read-tools.ts index 77ec7e38f..b98f7171c 100644 --- a/src/backend/ai/tools/read-tools.ts +++ b/plugins/ai/backend/tools/read-tools.ts @@ -8,7 +8,7 @@ import { createCurrentNetworkTopologyRepository, createCurrentSnippetRepository, createCurrentWorkspaceRepository, -} from "../../database/repositories/factory.js"; +} from "../../../../src/backend/database/repositories/factory.js"; import { num, objectSchema, type AiTool } from "./types.js"; /** diff --git a/src/backend/ai/tools/types.ts b/plugins/ai/backend/tools/types.ts similarity index 100% rename from src/backend/ai/tools/types.ts rename to plugins/ai/backend/tools/types.ts diff --git a/plugins/ai/frontend/index.mjs b/plugins/ai/frontend/index.mjs new file mode 100644 index 000000000..9931a9d61 --- /dev/null +++ b/plugins/ai/frontend/index.mjs @@ -0,0 +1,38 @@ +/** + * Frontend half of the ai plugin. + * + * AiPanel and the rest of src/ui/features/ai/ stay in core rather than + * moving here: TerminalAiPanel.tsx and TerminalProposalCard.tsx in + * src/ui/features/terminal/ (also core) import AiMessage, AiToolCall, + * useAiStream and ProposalCard directly, for the terminal's own docked AI + * panel. That is a real external dependency, the same reason session-manager + * stays in core for ssh-terminal rather than being copied into that plugin. + * + * The "ai" surface itself is not registered through registerTabComponent: + * it is a rail-view/right-dock panel baked into AppShell.tsx and + * tabUtils.tsx (railView === "ai", case "ai"), the same way the terminal's + * tab is baked into Terminal.tsx rather than going through the generic tab + * registry. What gates it is isTabTypeAvailable("ai") in + * src/ui/shell/pluginLoader.ts's BUILT_IN_TABS_BY_PLUGIN map, which is + * consulted directly by AppShell.tsx, CommandPalette.tsx and HostItem.tsx -- + * the same live mechanism ssh-terminal's "terminal" entry already uses. + * + * register()/unregister() exist for parity with the other first-party + * plugins' frontend halves. Nothing invokes them yet: there is no frontend + * plugin loader (see src/ui/tests/sidebar/plugin-extension-seam.test.tsx), + * and the "ai" surface does not need registerRailItem/registerTabComponent + * in the first place, since it is gated through BUILT_IN_TABS_BY_PLUGIN + * instead. + */ + +export const id = "ai"; +export const tabId = "ai"; + +export async function register() { + // Nothing to register: "ai" is a built-in rail-view/tab gated through + // isTabTypeAvailable, not a runtime-registered surface. See the header. +} + +export async function unregister() { + // Nothing to unregister; see register(). +} diff --git a/plugins/ai/manifest.json b/plugins/ai/manifest.json new file mode 100644 index 000000000..7e7746a14 --- /dev/null +++ b/plugins/ai/manifest.json @@ -0,0 +1,30 @@ +{ + "id": "ai", + "name": "AI Assistant", + "version": "1.0.0", + "description": "An AI assistant that can read your hosts, snippets, fleets, alerts and automations, and propose changes for you to approve.", + "author": { "name": "Termix" }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Productivity", + "icon": "Sparkles", + "engine": { "termix": ">=2.9.0", "api": "1" }, + "capabilities": { + "backend": true, + "frontend": true, + "electron": true, + "platforms": ["linux", "win32", "darwin"] + }, + "permissions": ["process:transport-owner"], + "contributes": { + "tabs": [ + { + "id": "ai", + "titleKey": "nav.ai", + "icon": "Sparkles", + "openFrom": ["rail", "palette"] + } + ] + }, + "sidecars": [] +} diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 41bf232e9..dc5b36cb1 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -32,7 +32,7 @@ import { registerTailscaleRoutes } from "./routes/tailscale-routes.js"; import vaultRoutes from "./routes/vault.js"; import secretSourceRoutes from "./routes/secret-sources.js"; import alertRulesRoutes from "./routes/alert-rules-routes.js"; -import aiRoutes from "../ai/index.js"; +import aiDispatch from "./routes/ai-dispatch.js"; import automationsRoutes from "./routes/automations.js"; import syncRoutes from "./routes/sync.js"; import pluginApiRoutes from "./routes/plugin-api-routes.js"; @@ -1777,7 +1777,7 @@ app.use("/secret-sources", secretSourceRoutes); // Before the alert routes, which are mounted at the root and would otherwise // have first claim on the path. app.use("/automations", automationsRoutes); -app.use("/ai", aiRoutes); +app.use("/ai", aiDispatch); app.use("/", alertRulesRoutes); app.use("/sync", syncRoutes); app.use("/plugins", pluginRoutes); diff --git a/src/backend/database/routes/ai-dispatch.ts b/src/backend/database/routes/ai-dispatch.ts new file mode 100644 index 000000000..fd7d64907 --- /dev/null +++ b/src/backend/database/routes/ai-dispatch.ts @@ -0,0 +1,46 @@ +// Mounted at /ai. The AI feature ships as a first-party in-process plugin +// (plugins/ai) rather than a route file imported directly here, so this +// module exists to keep /ai/* working at its original path without handing +// the plugin a reference to the shared app instance. The plugin runtime +// registers its router via registerAiRouter when it activates, and clears it +// on deactivate, so a disabled plugin falls back to the 404 below. +// +// Unlike /plugin-api, this dispatcher applies no auth of its own: it forwards +// to exactly one first-party router (the AI plugin's own), and every route in +// that router already applies authenticateJWT itself, the same as every other +// router mounted alongside it here (/users, /host, /snippets, ...). /plugin-api +// gates at the mount point because it forwards to arbitrary third-party plugin +// code that cannot be assumed to gate itself; that reasoning does not apply to +// a single first-party router that already does. + +import type { Request, Response, NextFunction, Router } from "express"; +import { databaseLogger } from "../../utils/logger.js"; + +let activeRouter: Router | null = null; + +export function registerAiRouter(router: Router): void { + activeRouter = router; + databaseLogger.info("Registered AI plugin router", { + operation: "ai_dispatch_register", + }); +} + +export function unregisterAiRouter(): void { + if (!activeRouter) return; + activeRouter = null; + databaseLogger.info("Unregistered AI plugin router", { + operation: "ai_dispatch_register", + }); +} + +export default function aiDispatch( + req: Request, + res: Response, + next: NextFunction, +): void { + if (!activeRouter) { + res.status(404).json({ error: "The AI assistant is not available" }); + return; + } + activeRouter(req, res, next); +} diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 0e54ace69..c5d1bd9dd 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -14,7 +14,6 @@ import { AUDIT_FORWARD_URL_SETTING, } from "../../utils/audit-forwarder.js"; import { getTelemetryEnvOverride } from "../../utils/analytics.js"; -import { AI_PRIVATE_ALLOWLIST_KEY, parseAllowlist } from "../../ai/egress.js"; import { STEP_CA_PRIVATE_ALLOWLIST_KEY } from "../../utils/step-ca-egress.js"; import { SECRET_SOURCE_PRIVATE_ALLOWLIST_KEY } from "../../utils/secret-source-egress.js"; import { @@ -886,206 +885,6 @@ export function registerUserSettingsRoutes( }, ); - /** - * @openapi - * /users/ai-enabled: - * get: - * summary: Get whether the AI assistant is enabled instance-wide - * tags: - * - Users - * responses: - * 200: - * description: AI enabled status. - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - */ - router.get("/ai-enabled", authenticateJWT, async (_req, res) => { - try { - res.json({ - // Defaults to false so upgrading an install never turns the assistant - // on without an admin deciding to. - enabled: await createCurrentSettingsRepository().getBoolean( - "ai_globally_enabled", - false, - ), - }); - } catch (err) { - authLogger.error("Failed to get AI enabled setting", err); - res.status(500).json({ error: "Failed to get AI enabled setting" }); - } - }); - - /** - * @openapi - * /users/ai-enabled: - * patch: - * summary: Update the instance-wide AI assistant setting (admin only) - * description: Turning this off hides and blocks the assistant for every user, whatever their own preference says. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * responses: - * 200: - * description: Setting updated. - * 403: - * description: Not authorized. - */ - router.patch("/ai-enabled", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - const { enabled } = req.body; - if (typeof enabled !== "boolean") { - return res.status(400).json({ error: "enabled must be a boolean" }); - } - await createCurrentSettingsRepository().set( - "ai_globally_enabled", - enabled ? "true" : "false", - ); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "update_ai_enabled", - resourceType: "setting", - details: JSON.stringify({ enabled }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ enabled }); - } catch (err) { - authLogger.error("Failed to update AI enabled setting", err); - res.status(500).json({ error: "Failed to update AI enabled setting" }); - } - }); - - /** - * @openapi - * /users/ai-private-endpoints: - * get: - * summary: Get the allowlist of private AI endpoint hosts - * tags: - * - Users - * responses: - * 200: - * description: Allowed hosts. - */ - router.get("/ai-private-endpoints", authenticateJWT, async (_req, res) => { - try { - const raw = await createCurrentSettingsRepository().get( - AI_PRIVATE_ALLOWLIST_KEY, - ); - res.json({ hosts: parseAllowlist(raw) }); - } catch (err) { - authLogger.error("Failed to get AI private endpoint allowlist", err); - res.status(500).json({ error: "Failed to get the allowlist" }); - } - }); - - /** - * @openapi - * /users/ai-private-endpoints: - * patch: - * summary: Replace the allowlist of private AI endpoint hosts (admin only) - * description: > - * Providers on private or loopback addresses, such as a self-hosted - * Ollama, are refused unless their host appears here. Without this an - * ordinary user could point a provider at an internal service and use - * the server as a probe of its own network. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * hosts: - * type: array - * items: - * type: string - * responses: - * 200: - * description: Allowlist updated. - * 403: - * description: Not authorized. - */ - router.patch("/ai-private-endpoints", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - - const { hosts } = req.body; - if (!Array.isArray(hosts)) { - return res.status(400).json({ error: "hosts must be an array" }); - } - if (hosts.length > 50) { - return res.status(400).json({ error: "At most 50 hosts are allowed" }); - } - - const cleaned: string[] = []; - for (const entry of hosts) { - if (typeof entry !== "string") { - return res.status(400).json({ error: "Each host must be a string" }); - } - const host = entry.trim().toLowerCase(); - if (!host) continue; - // A bare host, not a URL: no scheme, path, port or whitespace. - if (!/^[a-z0-9._:-]+$/.test(host)) { - return res - .status(400) - .json({ error: `${entry} is not a valid hostname` }); - } - if (!cleaned.includes(host)) cleaned.push(host); - } - - await createCurrentSettingsRepository().set( - AI_PRIVATE_ALLOWLIST_KEY, - JSON.stringify(cleaned), - ); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "update_ai_private_endpoints", - resourceType: "setting", - details: JSON.stringify({ hosts: cleaned }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ hosts: cleaned }); - } catch (err) { - authLogger.error("Failed to update AI private endpoint allowlist", err); - res.status(500).json({ error: "Failed to update the allowlist" }); - } - }); - /** * GET/PATCH a comma-list of private hosts an outbound feature may reach. * Shared by notifications and Step CA; each keeps its own setting key. diff --git a/src/backend/plugins/first-party.ts b/src/backend/plugins/first-party.ts index 2e6288eca..8f81bb939 100644 --- a/src/backend/plugins/first-party.ts +++ b/src/backend/plugins/first-party.ts @@ -45,11 +45,21 @@ * implements the same jump-host/SOCKS5/TOTP/Warpgate/OPKSSH/Vault * keyboard-interactive handshakes the terminal does -- none of which the * worker ctx.ssh API (connect + single exec-and-collect) has a hook for. + * + * ai is here for a different reason: it does not own a transport at all, but + * it writes directly across hosts, snippets, fleets, alert rules and + * automations through their repositories, runs approved commands over the + * shared SSH pool via resolveHostById and withConnection, and streams its + * chat replies as server-sent events. ctx.hosts is a 13-field read-only view + * with no write methods and no snippets/fleets/alerts/automations surface at + * all, and ctx.http.route replies with one postMessage value rather than a + * stream, so none of that fits the worker ctx either. */ export const FIRST_PARTY_PLUGIN_IDS: ReadonlySet = new Set([ "ssh-terminal", "docker", "host-metrics", + "ai", ]); export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner"; diff --git a/src/backend/starter.ts b/src/backend/starter.ts index e03196cfb..8ae3c1131 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -299,6 +299,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { // ssh-terminal, docker and host-metrics plugins start their own servers, // so disabling any of them stops its WS/HTTP server. See // plugins/ssh-terminal, plugins/docker and plugins/host-metrics. + // AI is also absent: the ai plugin registers its router with the /ai + // dispatcher in database.ts on activate, so disabling it makes /ai/* 404 + // instead of leaving a dead import here. See plugins/ai. await import("./hosts/tunnel/index.js"); await import("./hosts/file-manager/index.js"); await import("./hosts/tmux/index.js"); diff --git a/src/backend/tests/ai/context.test.ts b/src/backend/tests/plugins/ai/context.test.ts similarity index 95% rename from src/backend/tests/ai/context.test.ts rename to src/backend/tests/plugins/ai/context.test.ts index a800fdfb9..35310ae0e 100644 --- a/src/backend/tests/ai/context.test.ts +++ b/src/backend/tests/plugins/ai/context.test.ts @@ -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 }; diff --git a/src/backend/tests/ai/egress.test.ts b/src/backend/tests/plugins/ai/egress.test.ts similarity index 98% rename from src/backend/tests/ai/egress.test.ts rename to src/backend/tests/plugins/ai/egress.test.ts index b0dafc35f..e5cfdecb6 100644 --- a/src/backend/tests/ai/egress.test.ts +++ b/src/backend/tests/plugins/ai/egress.test.ts @@ -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 diff --git a/src/backend/tests/ai/engine.test.ts b/src/backend/tests/plugins/ai/engine.test.ts similarity index 94% rename from src/backend/tests/ai/engine.test.ts rename to src/backend/tests/plugins/ai/engine.test.ts index 85bb40cce..213bfd5ea 100644 --- a/src/backend/tests/ai/engine.test.ts +++ b/src/backend/tests/plugins/ai/engine.test.ts @@ -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* () { diff --git a/src/backend/tests/ai/gating.test.ts b/src/backend/tests/plugins/ai/gating.test.ts similarity index 96% rename from src/backend/tests/ai/gating.test.ts rename to src/backend/tests/plugins/ai/gating.test.ts index 32fd7c927..74f7e7948 100644 --- a/src/backend/tests/ai/gating.test.ts +++ b/src/backend/tests/plugins/ai/gating.test.ts @@ -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(() => { diff --git a/src/backend/tests/ai/provider-http.test.ts b/src/backend/tests/plugins/ai/providers/provider-http.test.ts similarity index 79% rename from src/backend/tests/ai/provider-http.test.ts rename to src/backend/tests/plugins/ai/providers/provider-http.test.ts index 928b62619..e86d815a0 100644 --- a/src/backend/tests/ai/provider-http.test.ts +++ b/src/backend/tests/plugins/ai/providers/provider-http.test.ts @@ -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(() => { diff --git a/src/backend/tests/ai/providers.test.ts b/src/backend/tests/plugins/ai/providers/providers.test.ts similarity index 93% rename from src/backend/tests/ai/providers.test.ts rename to src/backend/tests/plugins/ai/providers/providers.test.ts index 54f19e22a..6275ec021 100644 --- a/src/backend/tests/ai/providers.test.ts +++ b/src/backend/tests/plugins/ai/providers/providers.test.ts @@ -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 { diff --git a/src/backend/tests/ai/redaction.test.ts b/src/backend/tests/plugins/ai/redaction.test.ts similarity index 95% rename from src/backend/tests/ai/redaction.test.ts rename to src/backend/tests/plugins/ai/redaction.test.ts index b00a05ec4..858939a78 100644 --- a/src/backend/tests/ai/redaction.test.ts +++ b/src/backend/tests/plugins/ai/redaction.test.ts @@ -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", () => { diff --git a/src/backend/tests/ai/command-allowlist.test.ts b/src/backend/tests/plugins/ai/tools/command-allowlist.test.ts similarity index 96% rename from src/backend/tests/ai/command-allowlist.test.ts rename to src/backend/tests/plugins/ai/tools/command-allowlist.test.ts index a901cd9e3..6b1d97037 100644 --- a/src/backend/tests/ai/command-allowlist.test.ts +++ b/src/backend/tests/plugins/ai/tools/command-allowlist.test.ts @@ -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 diff --git a/src/backend/tests/ai/proposal-executor.test.ts b/src/backend/tests/plugins/ai/tools/proposal-executor.test.ts similarity index 95% rename from src/backend/tests/ai/proposal-executor.test.ts rename to src/backend/tests/plugins/ai/tools/proposal-executor.test.ts index e19943f03..166fef74e 100644 --- a/src/backend/tests/ai/proposal-executor.test.ts +++ b/src/backend/tests/plugins/ai/tools/proposal-executor.test.ts @@ -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(() => { diff --git a/src/backend/tests/ai/tool-catalog.test.ts b/src/backend/tests/plugins/ai/tools/tool-catalog.test.ts similarity index 98% rename from src/backend/tests/ai/tool-catalog.test.ts rename to src/backend/tests/plugins/ai/tools/tool-catalog.test.ts index 91285c699..c34cb285e 100644 --- a/src/backend/tests/ai/tool-catalog.test.ts +++ b/src/backend/tests/plugins/ai/tools/tool-catalog.test.ts @@ -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. diff --git a/src/backend/tests/plugins/first-party.test.ts b/src/backend/tests/plugins/first-party.test.ts index 37aec29e9..6d431924a 100644 --- a/src/backend/tests/plugins/first-party.test.ts +++ b/src/backend/tests/plugins/first-party.test.ts @@ -39,18 +39,20 @@ function manifest(overrides: Record = {}) { } 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"] }), diff --git a/src/ui/api/ai-api.ts b/src/ui/api/ai-api.ts index bbc754dea..8188c0b20 100644 --- a/src/ui/api/ai-api.ts +++ b/src/ui/api/ai-api.ts @@ -205,7 +205,7 @@ export async function markAiProposalRunInTerminal( export async function getAiGloballyEnabled(): Promise { try { - return (await authApi.get("/users/ai-enabled")).data.enabled; + return (await authApi.get("/ai/enabled")).data.enabled; } catch (error) { throw handleApiError(error, "get AI enabled setting"); } @@ -213,7 +213,7 @@ export async function getAiGloballyEnabled(): Promise { export async function setAiGloballyEnabled(enabled: boolean): Promise { try { - return (await authApi.patch("/users/ai-enabled", { enabled })).data.enabled; + return (await authApi.patch("/ai/enabled", { enabled })).data.enabled; } catch (error) { throw handleApiError(error, "update AI enabled setting"); } @@ -221,7 +221,7 @@ export async function setAiGloballyEnabled(enabled: boolean): Promise { export async function getAiPrivateEndpoints(): Promise { try { - return (await authApi.get("/users/ai-private-endpoints")).data.hosts; + return (await authApi.get("/ai/private-endpoints")).data.hosts; } catch (error) { throw handleApiError(error, "get AI endpoint allowlist"); } @@ -231,8 +231,7 @@ export async function setAiPrivateEndpoints( hosts: string[], ): Promise { try { - return (await authApi.patch("/users/ai-private-endpoints", { hosts })).data - .hosts; + return (await authApi.patch("/ai/private-endpoints", { hosts })).data.hosts; } catch (error) { throw handleApiError(error, "update AI endpoint allowlist"); } diff --git a/src/ui/shell/pluginLoader.ts b/src/ui/shell/pluginLoader.ts index 274faf1a2..8afa9f58b 100644 --- a/src/ui/shell/pluginLoader.ts +++ b/src/ui/shell/pluginLoader.ts @@ -22,9 +22,17 @@ import { getPlugins, type PluginSummary } from "@/api/plugins-api"; /** * Built-in tab types owned by a plugin, keyed by plugin id. The terminal has * no rail item: it opens from a host, the command palette or quick connect. + * + * ai is a rail-view/right-dock panel rather than a tab opened via + * registerTabComponent (AiPanel is rendered directly by AppShell.tsx and + * tabUtils.tsx, the same way TerminalTabContent is for the terminal), so it + * belongs here for the same reason: a built-in surface gated by whether its + * owning plugin is installed and enabled, not something registered at + * runtime. */ const BUILT_IN_TABS_BY_PLUGIN: Record = { "ssh-terminal": ["terminal"], + ai: ["ai"], }; let pluginEnabled = new Map(); diff --git a/src/ui/tests/features/ai/labels.test.ts b/src/ui/tests/features/ai/labels.test.ts index 0062d4ac6..674b857ad 100644 --- a/src/ui/tests/features/ai/labels.test.ts +++ b/src/ui/tests/features/ai/labels.test.ts @@ -5,7 +5,7 @@ import { mentionLabel, toolLabel, } from "@/features/ai/labels"; -import { AI_TOOLS } from "../../../../backend/ai/tools/catalog"; +import { AI_TOOLS } from "../../../../../plugins/ai/backend/tools/catalog"; describe("humanize", () => { it("splits camelCase into words", () => { diff --git a/src/ui/tests/shell/pluginLoader.test.ts b/src/ui/tests/shell/pluginLoader.test.ts index 2c84fb562..70ca86666 100644 --- a/src/ui/tests/shell/pluginLoader.test.ts +++ b/src/ui/tests/shell/pluginLoader.test.ts @@ -49,6 +49,24 @@ function plugin(overrides: Partial = {}): PluginSummary { }; } +function aiPlugin(overrides: Partial = {}): PluginSummary { + return plugin({ + id: "ai", + name: "AI Assistant", + contributes: { + tabs: [ + { + id: "ai", + titleKey: "nav.ai", + icon: "Sparkles", + openFrom: ["rail", "palette"], + }, + ], + }, + ...overrides, + }); +} + describe("plugin state applied to the shell", () => { afterEach(() => { resetPluginState(); @@ -139,6 +157,30 @@ describe("plugin state applied to the shell", () => { ); }); + it("allows the ai tab while the plugin is enabled", () => { + applyPluginState([aiPlugin({ enabled: true })]); + expect(isTabTypeAvailable("ai")).toBe(true); + }); + + it("blocks the ai tab when the plugin is disabled", () => { + applyPluginState([aiPlugin({ enabled: false })]); + expect(isTabTypeAvailable("ai")).toBe(false); + }); + + it("allows the ai tab again when the plugin is re-enabled", () => { + applyPluginState([aiPlugin({ enabled: false })]); + expect(isTabTypeAvailable("ai")).toBe(false); + + applyPluginState([aiPlugin({ enabled: true })]); + expect(isTabTypeAvailable("ai")).toBe(true); + }); + + it("disabling ai does not affect the terminal, and vice versa", () => { + applyPluginState([aiPlugin({ enabled: false }), plugin({ enabled: true })]); + expect(isTabTypeAvailable("ai")).toBe(false); + expect(isTabTypeAvailable("terminal")).toBe(true); + }); + it("reports enablement, defaulting to true before the first load", () => { expect(isPluginEnabled("ssh-terminal")).toBe(true); diff --git a/tsconfig.plugins.json b/tsconfig.plugins.json index 7d8b25ed9..a2a273700 100644 --- a/tsconfig.plugins.json +++ b/tsconfig.plugins.json @@ -28,7 +28,8 @@ }, "include": [ "plugins/docker/backend/**/*.ts", - "plugins/host-metrics/backend/**/*.ts" + "plugins/host-metrics/backend/**/*.ts", + "plugins/ai/backend/**/*.ts" ], "exclude": ["src/**/*.test.ts"] }