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

+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.