mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
This commit is contained in:
1 parent
c58ccbfc3f
commit
504710b065
43 files changed
+568
-262
No files matched your search
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user