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

File renamed without changes.
@@ -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.
@@ -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,
@@ -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.
+65
View File
@@ -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;
}
}
File renamed without changes.
@@ -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";
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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<UserRecord | null> {
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();
}
File renamed without changes.
@@ -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. */
@@ -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";
/**
File renamed without changes.
+38
View File
@@ -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().
}
+30
View File
@@ -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": []
}
+2 -2
View File
@@ -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);
@@ -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);
}
@@ -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.
+10
View File
@@ -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<string> = new Set([
"ssh-terminal",
"docker",
"host-metrics",
"ai",
]);
export const TRANSPORT_OWNER_CAPABILITY = "process:transport-owner";
+3
View File
@@ -299,6 +299,9 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// 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");
@@ -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"] }),
+4 -5
View File
@@ -205,7 +205,7 @@ export async function markAiProposalRunInTerminal(
export async function getAiGloballyEnabled(): Promise<boolean> {
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<boolean> {
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
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<boolean> {
export async function getAiPrivateEndpoints(): Promise<string[]> {
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<string[]> {
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");
}
+8
View File
@@ -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<string, string[]> = {
"ssh-terminal": ["terminal"],
ai: ["ai"],
};
let pluginEnabled = new Map<string, boolean>();
+1 -1
View File
@@ -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", () => {
+42
View File
@@ -49,6 +49,24 @@ function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
};
}
function aiPlugin(overrides: Partial<PluginSummary> = {}): 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);
+2 -1
View File
@@ -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"]
}