mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
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.
|
||||
@@ -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.
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.
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.
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. */
|
||||
File renamed without changes.
@@ -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.
@@ -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().
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
-4
@@ -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(() => {
|
||||
+10
-7
@@ -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 {
|
||||
+5
-1
@@ -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
-1
@@ -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
|
||||
+7
-6
@@ -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(() => {
|
||||
+1
-1
@@ -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.
|
||||
@@ -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"] }),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user