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

+55
View File
@@ -0,0 +1,55 @@
/**
* The system prompt.
*
* Deliberately small: the assistant starts with almost no context and must call
* a read tool to learn anything. That keeps token cost predictable, makes every
* data access visible in the transcript, and means nothing is sent to a
* third-party provider that the user did not implicitly ask for.
*/
export function buildSystemPrompt(options: {
hostCount: number;
activeTab?: string | null;
allowReadOnlyCommands: boolean;
}): string {
const lines: string[] = [
"You are the assistant built into Termix, a self-hosted server management app.",
"You help the user manage their servers, snippets, automations, fleets and alerts.",
"",
"How you work:",
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
"- You cannot change anything directly. To make a change, call a propose_* tool; the user sees a card and approves or rejects it.",
"- You have no access to passwords, SSH keys, API keys or any other credential, and you never ask the user to paste one into the chat.",
"",
"Scope:",
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
"- Do not propose follow-up work you thought of yourself: no monitoring, no alert rules, no scripts, no snippets, no cleanup, unless that is what was requested.",
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
"- One request means one proposal at most. Do not bundle extras alongside it.",
"",
"How to answer:",
"- Lead with the answer. Keep responses short and concrete.",
"- When you propose something, say in one line what it does and why.",
"- If a request is ambiguous in a way that changes what you would propose, ask before proposing.",
"- Never claim you have done something. You propose; the user applies.",
];
if (options.allowReadOnlyCommands) {
lines.push(
"- The user has allowed you to run read-only diagnostic commands directly. Anything that changes state still has to be proposed.",
);
}
lines.push(
"",
"Current context:",
`- The user has ${options.hostCount} host${options.hostCount === 1 ? "" : "s"} configured.`,
);
if (options.activeTab) {
lines.push(`- They are currently looking at: ${options.activeTab}.`);
}
return lines.join("\n");
}
+118
View File
@@ -0,0 +1,118 @@
import { isIP } from "net";
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.
*
* Cloud providers are reached through the SSRF-guarded path, which refuses to
* resolve to a private address. That guard is exactly what a self-hosted Ollama
* on localhost trips over, so private destinations are permitted only when an
* admin has named the host. Without that split, any logged-in user could point
* a "provider" at an internal service and use the backend as an authenticated
* probe of the server's own network.
*/
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
export const DEFAULT_PRIVATE_ALLOWLIST = [
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
];
export function parseAllowlist(raw: string | null): string[] {
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
} catch {
return [...DEFAULT_PRIVATE_ALLOWLIST];
}
}
export async function readPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
AI_PRIVATE_ALLOWLIST_KEY,
);
return parseAllowlist(raw);
}
function normalizeHost(hostname: string): string {
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
}
/**
* True when the URL names a destination the SSRF guard would refuse. A bare
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
* private only if it is "localhost" -- anything else needs DNS resolution.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return false;
}
const host = normalizeHost(url.hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (isIP(host)) return isBlockedAddress(host);
return false;
}
export interface EgressDecision {
allowed: boolean;
/** True when the destination needs the allowlisted-private path. */
isPrivate: boolean;
reason?: string;
}
export function evaluateEgress(
rawUrl: string,
allowlist: string[],
): EgressDecision {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
}
if (!["http:", "https:"].includes(url.protocol)) {
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
}
if (url.username || url.password) {
return {
allowed: false,
isPrivate: false,
reason: "Credentials in URL are not allowed",
};
}
const host = normalizeHost(url.hostname);
const isPrivate = isPrivateDestination(rawUrl);
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
// An explicitly allowlisted hostname may resolve to a private address. It
// must use the private fetch path; sending it through safeOutboundFetch
// would reject it after DNS resolution and make hostname allowlist entries
// ineffective. Only administrators can write this list.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
return {
allowed: false,
isPrivate: true,
reason:
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
};
}
+150
View File
@@ -0,0 +1,150 @@
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { getAdapter } from "./providers/registry.js";
import type {
ChatMessage,
ProviderConfig,
ToolCall,
} from "./providers/types.js";
import { redact, redactToJson } from "./redaction.js";
import { getTool, toolDefinitions } from "./tools/catalog.js";
import {
isProposalDraft,
type ProposalDraft,
type ToolContext,
} from "./tools/types.js";
/**
* The agent loop: stream a turn, run any tools the model asked for, feed the
* results back, repeat. Bounded so a model that keeps calling tools cannot spin
* forever.
*/
const MAX_TURNS = 8;
export type EngineEvent =
| { type: "token"; text: string }
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
| { type: "tool_result"; name: string; result: unknown }
| { type: "proposal"; draft: ProposalDraft }
| { type: "assistant_message"; content: string; toolCalls: ToolCall[] }
| { type: "done" }
| { type: "error"; message: string };
export interface EngineOptions {
config: ProviderConfig;
model: string;
system: string;
history: ChatMessage[];
context: ToolContext;
signal?: AbortSignal;
}
export async function* runAgent(
options: EngineOptions,
): AsyncGenerator<EngineEvent> {
const adapter = getAdapter(options.config.providerType);
const tools = toolDefinitions();
const messages: ChatMessage[] = [...options.history];
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
let text = "";
const calls: ToolCall[] = [];
let failed = false;
try {
for await (const chunk of adapter.streamChat(options.config, {
model: options.model,
system: options.system,
messages,
tools,
signal: options.signal,
})) {
if (chunk.type === "text") {
text += chunk.text;
yield { type: "token", text: chunk.text };
} else if (chunk.type === "tool_call") {
calls.push(chunk.call);
} else if (chunk.type === "error") {
failed = true;
yield { type: "error", message: chunk.message };
}
}
} catch (error) {
const message = getErrorMessage(error, "The provider request failed");
yield { type: "error", message };
return;
}
if (failed) return;
yield { type: "assistant_message", content: text, toolCalls: calls };
if (!calls.length) {
yield { type: "done" };
return;
}
messages.push({ role: "assistant", content: text, toolCalls: calls });
for (const call of calls) {
yield { type: "tool_call", name: call.name, arguments: call.arguments };
const result = await runTool(call, options.context);
if (isProposalDraft(result)) {
// Closes the tool call before the proposal card is emitted. Without
// this the call has no matching result and renders as permanently
// running, even though the work is done and awaiting the user.
yield {
type: "tool_result",
name: call.name,
result: { status: "awaiting_user_approval" },
};
yield { type: "proposal", draft: result };
// The model is told the proposal is awaiting the user rather than done,
// so it does not go on to describe the change as applied.
messages.push({
role: "tool",
content: JSON.stringify({
status: "awaiting_user_approval",
summary: result.summary,
}),
toolCallId: call.id,
toolName: call.name,
});
continue;
}
yield { type: "tool_result", name: call.name, result: redact(result) };
messages.push({
role: "tool",
content: redactToJson(result),
toolCallId: call.id,
toolName: call.name,
});
}
}
// Ran out of turns with the model still calling tools.
yield {
type: "error",
message: "The assistant used too many steps without finishing.",
};
}
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
const tool = getTool(call.name);
// A model can emit any name it likes; only the catalog decides what runs.
if (!tool) {
return { error: `Unknown tool: ${call.name}` };
}
try {
return await tool.handler(call.arguments ?? {}, context);
} catch (error) {
return {
error: getErrorMessage(error, "The tool failed"),
};
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { NextFunction, Response } from "express";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
} from "../../../src/backend/database/repositories/factory.js";
/**
* Three gates, all checked on the server.
*
* The admin global is a hard kill switch: when it is off the feature does not
* exist for anyone, regardless of what any user has enabled. It defaults to
* false so upgrading an existing install turns nothing on by surprise.
*
* Mirrors the shape of isSharingEnabledForHost in the session-sharing routes,
* where the global also wins over the per-entity setting.
*/
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
export async function isAiGloballyEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean(
AI_GLOBAL_ENABLED_KEY,
false,
);
}
export interface AiAccess {
enabled: boolean;
allowReadOnlyCommands: boolean;
}
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
const globalEnabled = await isAiGloballyEnabled();
if (!globalEnabled) {
return { enabled: false, allowReadOnlyCommands: false };
}
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return {
// Null means the user was never asked, which is not consent.
enabled: preferences?.aiAssistantEnabled === true,
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
};
}
/** Rejects any AI request unless both gates are open. */
export function createAiGate() {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
): Promise<void> => {
if (!req.userId) {
res.status(401).json({ error: "Authentication required" });
return;
}
const access = await resolveAiAccess(req.userId);
if (!access.enabled) {
res.status(403).json({ error: "The AI assistant is not enabled" });
return;
}
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
next();
};
}
+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;
}
}
+162
View File
@@ -0,0 +1,162 @@
import Anthropic from "@anthropic-ai/sdk";
import { providerFetch } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
/**
* Model ids offered in the picker. Users can type any other id; this is a
* convenience list, not a restriction.
*/
export const ANTHROPIC_MODELS = [
"claude-opus-5",
"claude-sonnet-5",
"claude-haiku-4-5",
];
function createClient(config: ProviderConfig): Anthropic {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
return new Anthropic({
apiKey: config.apiKey,
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
// Routes the SDK's HTTP through the shared egress guard.
fetch: providerFetch as unknown as typeof fetch,
});
}
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
const messages: Anthropic.MessageParam[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: message.toolCallId ?? "",
content: message.content,
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const content: Anthropic.ContentBlockParam[] = [];
if (message.content)
content.push({ type: "text", text: message.content });
for (const call of message.toolCalls) {
content.push({
type: "tool_use",
id: call.id,
name: call.name,
input: call.arguments,
});
}
messages.push({ role: "assistant", content });
continue;
}
if (message.role === "system") continue;
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* The SDK throws its own typed errors rather than going through assertOk, so
* they are translated here to match what every other provider reports.
*/
function translateSdkError(error: unknown): never {
const status =
typeof (error as { status?: unknown })?.status === "number"
? (error as { status: number }).status
: undefined;
const detail = error instanceof Error ? error.message : String(error);
if (status === 429) {
throw new AiProviderError(
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
429,
);
}
if (status === 401 || status === 403) {
throw new AiProviderError(
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
status,
);
}
throw new AiProviderError(
status ? `Anthropic request failed (${status}): ${detail}` : detail,
status,
);
}
export const anthropicAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const client = createClient(config);
const stream = client.messages.stream({
model: request.model,
max_tokens: 16000,
system: request.system,
messages: toAnthropicMessages(request),
thinking: { type: "adaptive" },
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
})),
}
: {}),
...(request.signal ? { signal: request.signal } : {}),
});
let final: Anthropic.Message;
try {
for await (const event of stream) {
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
yield { type: "text", text: event.delta.text };
}
}
final = await stream.finalMessage();
} catch (error) {
translateSdkError(error);
}
for (const block of final.content) {
if (block.type === "tool_use") {
yield {
type: "tool_call",
call: {
id: block.id,
name: block.name,
arguments: (block.input ?? {}) as Record<string, unknown>,
},
};
}
}
yield { type: "done", stopReason: final.stop_reason ?? undefined };
},
async listModels(): Promise<string[]> {
return [...ANTHROPIC_MODELS];
},
};
+191
View File
@@ -0,0 +1,191 @@
import { assertOk, providerFetch, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
import { AiProviderError } from "./types.js";
const GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || GEMINI_DEFAULT_BASE;
}
/**
* Gemini has no tool role: a tool result is a user-side functionResponse part,
* and an assistant tool request is a model-side functionCall part.
*/
function toGeminiContents(request: ChatRequest): unknown[] {
const contents: unknown[] = [];
for (const message of request.messages) {
if (message.role === "tool") {
contents.push({
role: "user",
parts: [
{
functionResponse: {
name: message.toolName ?? "tool",
response: { result: message.content },
},
},
],
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
const parts: unknown[] = [];
if (message.content) parts.push({ text: message.content });
for (const call of message.toolCalls) {
// thoughtSignature must be returned exactly as received or Gemini
// 2.5+ rejects the turn with a 400.
parts.push({
functionCall: { name: call.name, args: call.arguments },
...(call.providerSignature
? { thoughtSignature: call.providerSignature }
: {}),
});
}
contents.push({ role: "model", parts });
continue;
}
if (message.role === "system") continue;
contents.push({
role: message.role === "assistant" ? "model" : "user",
parts: [{ text: message.content }],
});
}
return contents;
}
/**
* Gemini rejects the JSON Schema keywords it does not implement, so the tool
* schemas are trimmed to the subset it accepts.
*/
function toGeminiSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
const source = schema as Record<string, unknown>;
const output: Record<string, unknown> = {};
for (const [key, value] of Object.entries(source)) {
if (key === "additionalProperties" || key === "$schema") continue;
if (key === "properties" && value && typeof value === "object") {
const properties: Record<string, unknown> = {};
for (const [name, child] of Object.entries(
value as Record<string, unknown>,
)) {
properties[name] = toGeminiSchema(child);
}
output[key] = properties;
continue;
}
output[key] = toGeminiSchema(value);
}
return output;
}
export const geminiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
if (!config.apiKey) {
throw new AiProviderError("This provider needs an API key");
}
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
const response = await providerFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": config.apiKey,
},
signal: request.signal,
body: JSON.stringify({
systemInstruction: { parts: [{ text: request.system }] },
contents: toGeminiContents(request),
...(request.tools.length
? {
tools: [
{
functionDeclarations: request.tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: toGeminiSchema(tool.parameters),
})),
},
],
}
: {}),
}),
});
await assertOk(response, "Gemini");
let index = 0;
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const candidate = frame.candidates?.[0];
if (!candidate) continue;
if (candidate.finishReason) stopReason = candidate.finishReason;
for (const part of candidate.content?.parts ?? []) {
if (typeof part.text === "string" && part.text) {
yield { type: "text", text: part.text };
}
if (part.functionCall?.name) {
yield {
type: "tool_call",
call: {
id: `call_${part.functionCall.name}_${index++}`,
name: part.functionCall.name,
arguments: (part.functionCall.args ?? {}) as Record<
string,
unknown
>,
// Carried so the next turn can echo it back; without it Gemini
// 400s as soon as a tool has been used once.
providerSignature: part.thoughtSignature,
},
};
}
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
if (!config.apiKey) return [];
const response = await providerFetch(
`${baseFor(config).replace(/\/+$/, "")}/models`,
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
);
await assertOk(response, "Gemini");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => String(entry.name ?? "").replace(/^models\//, ""))
.filter((name: string) => name.length > 0)
.sort();
},
};
+174
View File
@@ -0,0 +1,174 @@
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";
/**
* Every outbound provider request goes through here so the egress rules cannot
* be bypassed by an adapter calling fetch directly.
*
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
* connect time. Allowlisted private hosts cannot use it (its whole job is to
* refuse them), so they use the installed Undici fetch implementation with
* its matching proxy dispatcher -- still respecting proxy configuration.
*/
export async function providerFetch(
url: string,
init: RequestInit,
): Promise<Response> {
const allowlist = await readPrivateAllowlist();
const decision = evaluateEgress(url, allowlist);
if (!decision.allowed) {
throw new AiProviderError(decision.reason ?? "Destination not allowed");
}
if (decision.isPrivate) {
return fetchWithProxy(url, init);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
}
export function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
}
/**
* Yields the data payload of each SSE frame. Providers differ in what they put
* inside, so parsing the JSON is left to the caller.
*/
export async function* readSseLines(
response: Response,
): AsyncGenerator<string> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line.startsWith("data:")) {
yield line.slice(5).trim();
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/** Yields one parsed JSON object per line, for newline-delimited streams. */
export async function* readJsonLines(
response: Response,
): AsyncGenerator<unknown> {
const body = response.body;
if (!body) return;
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex !== -1) {
const line = buffer.slice(0, newlineIndex).trim();
buffer = buffer.slice(newlineIndex + 1);
if (line) {
try {
yield JSON.parse(line);
} catch {
// A partial or malformed frame is skipped rather than failing the
// whole stream.
}
}
newlineIndex = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
/**
* Digs the human-readable message out of an error body.
*
* Every provider nests it differently, and dumping the raw JSON produced
* something that got cut off mid-sentence. Falls back to a trimmed snippet
* when the shape is unfamiliar.
*/
function extractProviderMessage(body: string): string {
try {
const parsed = JSON.parse(body);
const message =
parsed?.error?.message ??
parsed?.error?.["message"] ??
parsed?.message ??
(typeof parsed?.error === "string" ? parsed.error : null);
if (typeof message === "string" && message.trim()) {
return message.trim();
}
} catch {
// Not JSON; fall through to the snippet.
}
const trimmed = body.trim();
if (!trimmed) return "";
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
}
export async function assertOk(
response: Response,
provider: string,
): Promise<void> {
if (response.ok) return;
let body = "";
try {
body = await response.text();
} catch {
body = "";
}
const detail = extractProviderMessage(body);
// Rate limits and auth failures are the two the user can actually act on,
// so they say what to do instead of reading like an internal failure.
if (response.status === 429) {
throw new AiProviderError(
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
detail ? ` (${detail})` : ""
}`,
429,
);
}
if (response.status === 401 || response.status === 403) {
throw new AiProviderError(
`${provider} rejected the API key. Check that it is correct and still active.${
detail ? ` (${detail})` : ""
}`,
response.status,
);
}
throw new AiProviderError(
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
response.status,
);
}
+131
View File
@@ -0,0 +1,131 @@
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
} from "./types.js";
export const OLLAMA_DEFAULT_BASE = "http://localhost:11434";
function baseFor(config: ProviderConfig): string {
return config.baseUrl?.trim() || OLLAMA_DEFAULT_BASE;
}
function toOllamaMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
content: message.content,
...(message.toolName ? { tool_name: message.toolName } : {}),
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content,
tool_calls: message.toolCalls.map((call) => ({
function: { name: call.name, arguments: call.arguments },
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
export const ollamaAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOllamaMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "Ollama");
let index = 0;
let stopReason: string | undefined;
// Ollama streams newline-delimited JSON rather than SSE.
for await (const frame of readJsonLines(response)) {
const payload = frame as any;
if (
typeof payload.message?.content === "string" &&
payload.message.content
) {
yield { type: "text", text: payload.message.content };
}
for (const call of payload.message?.tool_calls ?? []) {
const name = call.function?.name;
if (!name) continue;
const rawArgs = call.function?.arguments;
// Ollama sends an object, but some builds send a JSON string.
let args: Record<string, unknown> = {};
if (rawArgs && typeof rawArgs === "object") {
args = rawArgs as Record<string, unknown>;
} else if (typeof rawArgs === "string" && rawArgs.trim()) {
try {
args = JSON.parse(rawArgs);
} catch {
args = {};
}
}
yield {
type: "tool_call",
call: { id: `call_${name}_${index++}`, name, arguments: args },
};
}
if (payload.done) {
stopReason = payload.done_reason ?? "stop";
break;
}
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
method: "GET",
});
await assertOk(response, "Ollama");
const body: any = await response.json();
return (body.models ?? [])
.map((entry: any) => entry.name)
.filter((name: unknown): name is string => typeof name === "string")
.sort();
},
};
+182
View File
@@ -0,0 +1,182 @@
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
ProviderAdapter,
ProviderConfig,
ToolCall,
} from "./types.js";
import { AiProviderError } from "./types.js";
const OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
function baseFor(config: ProviderConfig): string {
if (config.baseUrl?.trim()) return config.baseUrl.trim();
if (config.providerType === "openai") return OPENAI_DEFAULT_BASE;
throw new AiProviderError("This provider needs a base URL");
}
function toOpenAiMessages(request: ChatRequest): unknown[] {
const messages: unknown[] = [{ role: "system", content: request.system }];
for (const message of request.messages) {
if (message.role === "tool") {
messages.push({
role: "tool",
tool_call_id: message.toolCallId,
content: message.content,
});
continue;
}
if (message.role === "assistant" && message.toolCalls?.length) {
messages.push({
role: "assistant",
content: message.content || null,
tool_calls: message.toolCalls.map((call) => ({
id: call.id,
type: "function",
function: {
name: call.name,
arguments: JSON.stringify(call.arguments),
},
})),
});
continue;
}
messages.push({ role: message.role, content: message.content });
}
return messages;
}
/**
* Tool call arguments arrive as JSON fragments spread across many deltas, so
* they are accumulated per index and only parsed once the stream ends.
*/
interface PartialCall {
id: string;
name: string;
args: string;
}
export const openAiAdapter: ProviderAdapter = {
async *streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const url = joinUrl(baseFor(config), "chat/completions");
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(url, {
method: "POST",
headers,
signal: request.signal,
body: JSON.stringify({
model: request.model,
messages: toOpenAiMessages(request),
stream: true,
...(request.tools.length
? {
tools: request.tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.parameters,
},
})),
}
: {}),
}),
});
await assertOk(response, "OpenAI");
const partial = new Map<number, PartialCall>();
let stopReason: string | undefined;
for await (const data of readSseLines(response)) {
if (data === "[DONE]") break;
let frame: any;
try {
frame = JSON.parse(data);
} catch {
continue;
}
const choice = frame.choices?.[0];
if (!choice) continue;
if (choice.finish_reason) stopReason = choice.finish_reason;
const delta = choice.delta;
if (!delta) continue;
if (typeof delta.content === "string" && delta.content) {
yield { type: "text", text: delta.content };
}
for (const call of delta.tool_calls ?? []) {
const index = call.index ?? 0;
const existing = partial.get(index) ?? { id: "", name: "", args: "" };
if (call.id) existing.id = call.id;
if (call.function?.name) existing.name = call.function.name;
if (call.function?.arguments) existing.args += call.function.arguments;
partial.set(index, existing);
}
}
for (const call of partial.values()) {
if (!call.name) continue;
yield { type: "tool_call", call: finalizeCall(call) };
}
yield { type: "done", stopReason };
},
async listModels(config: ProviderConfig): Promise<string[]> {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
method: "GET",
headers,
});
await assertOk(response, "OpenAI");
const body: any = await response.json();
return (body.data ?? [])
.map((entry: any) => entry.id)
.filter((id: unknown): id is string => typeof id === "string")
.sort();
},
};
export function finalizeCall(call: PartialCall): ToolCall {
let args: Record<string, unknown> = {};
if (call.args.trim()) {
try {
const parsed = JSON.parse(call.args);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
args = parsed as Record<string, unknown>;
}
} catch {
// A model that emitted malformed arguments gets an empty object; the
// tool's own schema validation reports the problem back to it.
args = {};
}
}
return {
id:
call.id || `call_${call.name}_${Math.random().toString(36).slice(2, 10)}`,
name: call.name,
arguments: args,
};
}
+64
View File
@@ -0,0 +1,64 @@
import { anthropicAdapter } from "./anthropic.js";
import { geminiAdapter } from "./gemini.js";
import { ollamaAdapter } from "./ollama.js";
import { openAiAdapter } from "./openai.js";
import {
AiProviderError,
type AiProviderType,
type ProviderAdapter,
} from "./types.js";
/**
* openai_compatible reuses the OpenAI adapter: OpenRouter, Groq, Mistral,
* DeepSeek, together.ai, LM Studio and vLLM all speak the same wire format,
* they differ only in base URL.
*/
const ADAPTERS: Record<AiProviderType, ProviderAdapter> = {
ollama: ollamaAdapter,
anthropic: anthropicAdapter,
openai: openAiAdapter,
gemini: geminiAdapter,
openai_compatible: openAiAdapter,
};
export function getAdapter(providerType: string): ProviderAdapter {
const adapter = ADAPTERS[providerType as AiProviderType];
if (!adapter) {
throw new AiProviderError(`Unknown provider type: ${providerType}`);
}
return adapter;
}
/** Provider types that cannot work without a base URL. */
export const REQUIRES_BASE_URL: AiProviderType[] = [
"ollama",
"openai_compatible",
];
/** Provider types that cannot work without an API key. */
export const REQUIRES_API_KEY: AiProviderType[] = [
"anthropic",
"openai",
"gemini",
];
/**
* Shown when a provider's model list cannot be fetched: no key entered yet,
* the endpoint is unreachable, or the vendor has no list endpoint. Users can
* always type a model id the list does not contain, so this is a starting
* point rather than a restriction.
*/
export const FALLBACK_MODELS: Record<AiProviderType, string[]> = {
ollama: [
"llama3.2",
"llama3.1",
"qwen2.5-coder",
"mistral",
"phi4",
"gemma2",
],
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
openai: ["gpt-5", "gpt-5-mini", "o4-mini", "gpt-4.1"],
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
openai_compatible: [],
};
+90
View File
@@ -0,0 +1,90 @@
/**
* The shape every provider adapter normalizes to. Adding a provider means
* translating its wire format into these events; nothing downstream (the
* engine, the tool dispatcher, the SSE route) knows which vendor is in use.
*/
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
export const AI_PROVIDER_TYPES: AiProviderType[] = [
"ollama",
"anthropic",
"openai",
"gemini",
"openai_compatible",
];
export function isAiProviderType(value: unknown): value is AiProviderType {
return (
typeof value === "string" && (AI_PROVIDER_TYPES as string[]).includes(value)
);
}
export interface ChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
/** Set on assistant turns that requested tools. */
toolCalls?: ToolCall[];
/** Set on tool turns, matching the id of the call being answered. */
toolCallId?: string;
toolName?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
/**
* Opaque provider state that has to be echoed back verbatim on the next
* turn. Gemini 2.5+ rejects a follow-up whose functionCall parts have lost
* their thoughtSignature, so this rides along rather than being dropped.
*/
providerSignature?: string;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ChatRequest {
model: string;
system: string;
messages: ChatMessage[];
tools: ToolDefinition[];
signal?: AbortSignal;
}
export type ChatChunk =
| { type: "text"; text: string }
| { type: "tool_call"; call: ToolCall }
| { type: "done"; stopReason?: string }
| { type: "error"; message: string };
export interface ProviderConfig {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
}
export interface ProviderAdapter {
/** Streams a single assistant turn. Tool execution happens in the engine. */
streamChat(
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk>;
/** Model ids to offer in the picker, best effort. */
listModels(config: ProviderConfig): Promise<string[]>;
}
export class AiProviderError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = "AiProviderError";
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Defense in depth for anything about to leave the server.
*
* Read tools already select explicit field allowlists rather than spreading
* rows, so nothing secret should reach here. This exists because "should" is
* not a guarantee: a future tool that forgets to project its fields would
* otherwise ship credentials to a third-party model provider.
*/
const SECRET_KEY_PATTERN =
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
/** Substring markers for keys that are not exact matches but still sensitive. */
const SECRET_KEY_SUBSTRINGS = [
"password",
"secret",
"privatekey",
"private_key",
"apikey",
"api_key",
"passphrase",
];
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
{
pattern:
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
label: "[redacted private key]",
},
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
{
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
label: "[redacted token]",
},
{
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
label: "Bearer [redacted]",
},
];
export const REDACTED = "[redacted]";
function isSecretKey(key: string): boolean {
if (SECRET_KEY_PATTERN.test(key)) return true;
const lower = key.toLowerCase();
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
}
export function redactString(value: string): string {
let output = value;
for (const { pattern, label } of VALUE_PATTERNS) {
output = output.replace(pattern, label);
}
return output;
}
/**
* Recursively drops secret-named fields and masks secret-shaped values.
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
*/
export function redact(value: unknown, depth = 0): unknown {
if (depth > 12) return REDACTED;
if (typeof value === "string") return redactString(value);
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.map((entry) => redact(entry, depth + 1));
}
const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (isSecretKey(key)) {
// Preserve the shape so the model can still reason about presence,
// without ever seeing the value.
output[key] = entry === null || entry === undefined ? null : REDACTED;
continue;
}
output[key] = redact(entry, depth + 1);
}
return output;
}
/** Convenience wrapper for serializing a tool result. */
export function redactToJson(value: unknown): string {
return JSON.stringify(redact(value));
}
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
import { proposeTools } from "./propose-tools.js";
import { readTools } from "./read-tools.js";
import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. Route-level RBAC gates the
* HTTP API, but tools run in-process with the calling user's identity and never
* pass through a router, so "the assistant cannot reach credentials or user
* administration" is a property of this list, not of the permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
*/
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
export function getTool(name: string): AiTool | undefined {
return BY_NAME.get(name);
}
export function listToolNames(): string[] {
return AI_TOOLS.map((tool) => tool.name);
}
/**
* Domains the assistant must never be able to reach, in any tool, ever.
* The catalog test asserts no tool name references these.
*/
export const FORBIDDEN_DOMAINS = [
"credential",
"vault",
"rbac",
"role",
"permission",
"user_admin",
"password",
"totp",
"webauthn",
"passkey",
"api_key",
"session",
"oidc",
"sso",
"ldap",
"termix_id",
"identity",
"certificate",
"opkssh",
"stepca",
"step_ca",
"acme",
"ssl",
"audit",
"sync",
"settings",
];
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(): ToolDefinitionShape[] {
return AI_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
}));
}
@@ -0,0 +1,136 @@
/**
* Which commands the assistant may run without a per-command approval click,
* for users who opted into read-only execution.
*
* The check parses the command into arguments and matches the resolved binary
* against the allowlist. Substring matching would be trivially defeated
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
* redirect or substitute a second command rejects the whole string outright.
*/
export const READ_ONLY_COMMANDS = new Set([
"df",
"du",
"free",
"uptime",
"uname",
"whoami",
"hostname",
"id",
"ps",
"top",
"systemctl",
"journalctl",
"docker",
"ip",
"ss",
"netstat",
"lsblk",
"cat",
"ls",
"stat",
"which",
"date",
"lscpu",
"vmstat",
"iostat",
]);
/** Characters that let one command become several. */
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
/** Subcommands that are safe for otherwise-powerful binaries. */
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
systemctl: new Set([
"status",
"list-units",
"list-unit-files",
"is-active",
"is-enabled",
"show",
]),
docker: new Set([
"ps",
"stats",
"images",
"logs",
"inspect",
"version",
"info",
]),
ip: new Set(["a", "addr", "link", "route", "neigh"]),
};
/** Paths `cat` may read. Anything else could disclose credentials. */
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
export interface CommandCheck {
allowed: boolean;
reason?: string;
}
export function isReadOnlyCommand(raw: string): CommandCheck {
const command = raw.trim();
if (!command) return { allowed: false, reason: "Empty command" };
if (SHELL_METACHARACTERS.test(command)) {
return {
allowed: false,
reason: "Command chaining, redirection and substitution are not allowed",
};
}
const parts = command.split(/\s+/).filter(Boolean);
if (!parts.length) return { allowed: false, reason: "Empty command" };
// Reject env-prefixed and privilege-escalating forms outright.
const head = parts[0];
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
return { allowed: false, reason: `${head} is not allowed` };
}
// A path like /usr/bin/df resolves to its basename.
const binary = head.includes("/")
? head.slice(head.lastIndexOf("/") + 1)
: head;
if (!READ_ONLY_COMMANDS.has(binary)) {
return {
allowed: false,
reason: `${binary} is not on the read-only allowlist`,
};
}
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
if (allowedSubcommands) {
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
if (!subcommand || !allowedSubcommands.has(subcommand)) {
return {
allowed: false,
reason:
`${binary} ${subcommand ?? ""}`.trim() +
" is not on the read-only allowlist",
};
}
}
if (binary === "cat") {
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
if (!targets.length) {
return { allowed: false, reason: "cat needs a file path" };
}
for (const target of targets) {
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
);
if (!permitted) {
return {
allowed: false,
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
};
}
}
}
return { allowed: true };
}
+332
View File
@@ -0,0 +1,332 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} 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 "../../../../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. */
const COMMAND_TIMEOUT_MS = 60_000;
/**
* Applies an approved proposal.
*
* The stored payload is treated as untrusted input even though the server wrote
* it: the proposal could have sat in the table across a release, and defending
* the apply path rather than the create path means one place to get right.
* Everything goes through the same repositories a human action uses, scoped to
* the approving user.
*/
export interface ApplyResult {
ok: boolean;
summary: string;
}
function requireNumber(value: unknown, field: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return parsed;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} is required`);
}
return value.trim();
}
function optionalString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export async function applyProposal(
kind: string,
payload: Record<string, unknown>,
userId: string,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
throw new Error(`Unknown proposal kind: ${kind}`);
}
switch (kind) {
case "propose_create_host": {
const created = await createCurrentHostRepository().create({
userId,
name: requireString(payload.name, "name"),
ip: requireString(payload.ip, "ip"),
port: Number(payload.port) || 22,
username: optionalString(payload.username) ?? "",
folder: optionalString(payload.folder) ?? "",
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
} as any);
return {
ok: true,
summary: `Created host ${(created as any).name ?? ""}`.trim(),
};
}
case "propose_update_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!existing) throw new Error("Host not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.ip !== undefined)
updates.ip = requireString(changes.ip, "ip");
if (changes.port !== undefined) updates.port = Number(changes.port);
if (changes.username !== undefined)
updates.username = optionalString(changes.username) ?? "";
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder) ?? "";
if (changes.tags !== undefined) {
updates.tags = JSON.stringify(
Array.isArray(changes.tags) ? changes.tags : [],
);
}
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentHostRepository().updateForUser(
userId,
hostId,
updates as any,
);
return { ok: true, summary: `Updated host ${hostId}` };
}
case "propose_delete_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const deleted = await createCurrentHostRepository().deleteForUser(
userId,
hostId,
);
if (!deleted) throw new Error("Host not found");
return { ok: true, summary: `Deleted host ${hostId}` };
}
case "propose_create_snippet": {
const created = await createCurrentSnippetRepository().createSnippet(
userId,
{
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
} as any,
);
return {
ok: true,
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_update_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.content !== undefined)
updates.content = requireString(changes.content, "content");
if (changes.description !== undefined)
updates.description = optionalString(changes.description);
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updates as any,
);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
case "propose_create_fleet": {
const fleet = await createCurrentFleetRepository().create(userId, {
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
} as any);
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
for (const raw of hostIds) {
const hostId = Number(raw);
if (!Number.isInteger(hostId) || hostId <= 0) continue;
// Only hosts the approving user owns can join their fleet.
const host = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!host) continue;
await createCurrentFleetRepository().addMember(
(fleet as any).id,
hostId,
);
added += 1;
}
return {
ok: true,
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
};
}
case "propose_create_alert_rule": {
const created = await createCurrentAlertRepository().createAlertRule({
userId,
name: requireString(payload.name, "name"),
hostId:
payload.hostId === null || payload.hostId === undefined
? null
: requireNumber(payload.hostId, "hostId"),
enabled: true,
triggerType: requireString(payload.triggerType, "triggerType"),
thresholdValue:
payload.thresholdValue === null ||
payload.thresholdValue === undefined
? null
: Number(payload.thresholdValue),
thresholdDurationSeconds:
payload.thresholdDurationSeconds === null ||
payload.thresholdDurationSeconds === undefined
? null
: Number(payload.thresholdDurationSeconds),
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
channelIds: [],
} as any);
return {
ok: true,
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an
// LLM-authored definition is held to exactly the human standard.
const validation = validateDefinition(payload.definition);
if (!validation.ok || !validation.definition) {
throw new Error(
validation.error ?? "The automation definition is invalid",
);
}
const created = await createCurrentAutomationRepository().create({
userId,
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: JSON.stringify(validation.definition),
// Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval.
enabled: false,
});
return {
ok: true,
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
};
}
case "propose_run_command": {
const hostId = requireNumber(payload.hostId, "hostId");
const command = requireString(payload.command, "command");
// resolveHostById, not the raw repository row: it runs the connect-level
// permission check, decrypts auth under the owner's key, and resolves the
// jump host chain. A plain row has none of that, so the SSH factory saw
// an unresolved jumpHosts field and failed the connection.
const host = await resolveHostById(hostId, userId);
if (!host) throw new Error("Host not found");
const result = await runCommandOnHost(host, command);
if (result.error) {
throw new Error(result.error);
}
return {
ok: true,
summary: result.output?.slice(0, 2000) ?? "(no output)",
};
}
default:
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
}
}
/**
* Runs one approved command over the shared SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
host: Record<string, any>,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withConnection(
getFleetPoolKey(host as any),
createFleetSshFactory(host as any),
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}: ${output}`.trim() };
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Kinds the route handles itself rather than through applyProposal.
* Empty: everything the assistant can propose can now be applied.
*/
export const ROUTE_APPLIED_KINDS = new Set<string>();
+279
View File
@@ -0,0 +1,279 @@
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
/**
* Propose tools never mutate anything. They return a draft that is stored as a
* pending proposal and rendered as a card; the change happens only when the
* user approves it, and the payload is re-validated at that point.
*/
export const proposeTools: AiTool[] = [
{
name: "propose_create_host",
description:
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
category: "propose",
parameters: objectSchema(
{
name: str("Display name for the host"),
ip: str("Hostname or IP address"),
port: num("SSH port (defaults to 22)"),
username: str("SSH username"),
folder: str("Folder to file the host under"),
tags: {
type: "array",
items: { type: "string" },
description: "Tags to apply",
},
},
["name", "ip"],
),
handler: async (args) =>
proposal("propose_create_host", `Add host ${String(args.name)}`, {
name: args.name,
ip: args.ip,
port: args.port ?? 22,
username: args.username ?? null,
folder: args.folder ?? null,
tags: args.tags ?? [],
}),
},
{
name: "propose_update_host",
description:
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to update"),
name: str("New display name"),
ip: str("New hostname or IP address"),
port: num("New SSH port"),
username: str("New SSH username"),
folder: str("New folder"),
tags: {
type: "array",
items: { type: "string" },
description: "Replacement tag list",
},
},
["hostId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of [
"name",
"ip",
"port",
"username",
"folder",
"tags",
]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_host",
`Update host ${String(args.hostId)}`,
{ hostId: args.hostId, changes },
);
},
},
{
name: "propose_delete_host",
description:
"Propose removing a host. Use sparingly and explain why in the reason.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to delete"),
reason: str("Why this host should be removed"),
},
["hostId", "reason"],
),
handler: async (args) =>
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
hostId: args.hostId,
reason: args.reason,
}),
},
{
name: "propose_create_snippet",
description:
"Propose saving a new command snippet the user can run against their hosts.",
category: "propose",
parameters: objectSchema(
{
name: str("Snippet name"),
content: str("The command text"),
description: str("What the snippet does"),
folder: str("Folder to file it under"),
},
["name", "content"],
),
handler: async (args) =>
proposal(
"propose_create_snippet",
`Create snippet ${String(args.name)}`,
{
name: args.name,
content: args.content,
description: args.description ?? null,
folder: args.folder ?? null,
},
),
},
{
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
name: str("New name"),
content: str("New command text"),
description: str("New description"),
folder: str("New folder"),
},
["snippetId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of ["name", "content", "description", "folder"]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_snippet",
`Update snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, changes },
);
},
},
{
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
reason: str("Why this snippet should be removed"),
},
["snippetId", "reason"],
),
handler: async (args) =>
proposal(
"propose_delete_snippet",
`Delete snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, reason: args.reason },
),
},
{
name: "propose_create_automation",
description:
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
category: "propose",
parameters: objectSchema(
{
name: str("Automation name"),
description: str("What the automation does"),
definition: {
type: "object",
description:
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
},
},
["name", "definition"],
),
handler: async (args) =>
proposal(
"propose_create_automation",
`Create automation ${String(args.name)}`,
{
name: args.name,
description: args.description ?? null,
definition: args.definition,
},
),
},
{
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
parameters: objectSchema(
{
name: str("Fleet name"),
description: str("What this fleet is for"),
hostIds: {
type: "array",
items: { type: "number" },
description: "Host ids to add as members",
},
},
["name"],
),
handler: async (args) =>
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
name: args.name,
description: args.description ?? null,
hostIds: args.hostIds ?? [],
}),
},
{
name: "propose_create_alert_rule",
description:
"Propose a new alert rule that fires when a host metric crosses a threshold.",
category: "propose",
parameters: objectSchema(
{
name: str("Rule name"),
hostId: num("Host id to watch, or omit to watch all hosts"),
triggerType: str(
"What to watch, for example cpu, memory, disk or host_status",
),
thresholdValue: num("Threshold to compare against"),
thresholdDurationSeconds: num(
"How long the breach must persist before firing",
),
cooldownMinutes: num("Minimum minutes between repeat firings"),
},
["name", "triggerType"],
),
handler: async (args) =>
proposal(
"propose_create_alert_rule",
`Create alert rule ${String(args.name)}`,
{
name: args.name,
hostId: args.hostId ?? null,
triggerType: args.triggerType,
thresholdValue: args.thresholdValue ?? null,
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
cooldownMinutes: args.cooldownMinutes ?? 15,
},
),
},
{
name: "propose_run_command",
description:
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to run on"),
command: str("The exact command to run"),
explanation: str("What the command does and why it is needed"),
},
["hostId", "command", "explanation"],
),
handler: async (args) =>
proposal(
"propose_run_command",
// Deliberately short: the card renders the command in its own block,
// so repeating it here made every card twice as tall as it needed.
`Run a command on host ${String(args.hostId)}`,
{
hostId: args.hostId,
command: args.command,
explanation: args.explanation,
},
),
},
];
+331
View File
@@ -0,0 +1,331 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentSnippetRepository,
createCurrentWorkspaceRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
* afterwards as a second line of defense, but the projection here is the
* primary control: a field that is never selected cannot leak.
*/
interface HostSummary {
id: number;
name: string | null;
ip: string | null;
port: number | null;
username: string | null;
folder: string | null;
tags: unknown;
protocol: string | null;
enableTerminal: boolean | null;
enableFileManager: boolean | null;
enableTunnel: boolean | null;
enableDocker: boolean | null;
}
function toHostSummary(host: Record<string, any>): HostSummary {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip ?? null,
port: host.port ?? null,
username: host.username ?? null,
folder: host.folder ?? null,
tags: host.tags ?? null,
protocol: host.protocol ?? null,
enableTerminal: host.enableTerminal ?? null,
enableFileManager: host.enableFileManager ?? null,
enableTunnel: host.enableTunnel ?? null,
enableDocker: host.enableDocker ?? null,
};
}
export const readTools: AiTool[] = [
{
name: "list_hosts",
description:
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await createCurrentHostRepository().listByUserId(
context.userId,
);
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
},
},
{
name: "get_host",
description:
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
category: "read",
parameters: objectSchema(
{ hostId: num("The host id, as returned by list_hosts") },
["hostId"],
),
handler: async (args, context) => {
const hostId = Number(args.hostId);
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host as any) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await createCurrentFleetRepository().listByUser(
context.userId,
);
return {
fleets: fleets.map((fleet: any) => ({
id: fleet.id,
name: fleet.name,
description: fleet.description ?? null,
})),
};
},
},
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
context.userId,
);
return {
snippets: snippets.map((snippet: any) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? null,
description: snippet.description ?? null,
})),
};
},
},
{
name: "list_automations",
description:
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await createCurrentAutomationRepository().list(
context.userId,
);
return {
automations: automations.map((automation: any) => ({
id: automation.id,
name: automation.name,
description: automation.description ?? null,
enabled: automation.enabled,
lastRunAt: automation.lastRunAt ?? null,
lastRunStatus: automation.lastRunStatus ?? null,
})),
};
},
},
{
name: "get_automation",
description:
"Get one automation's full definition (trigger and steps) by id.",
category: "read",
parameters: objectSchema(
{
automationId: num("The automation id, as returned by list_automations"),
},
["automationId"],
),
handler: async (args, context) => {
const automation = await createCurrentAutomationRepository().findForUser(
Number(args.automationId),
context.userId,
);
if (!automation) return { error: "Automation not found" };
return {
automation: {
id: (automation as any).id,
name: (automation as any).name,
enabled: (automation as any).enabled,
definition: (automation as any).definition,
},
};
},
},
{
name: "list_workspaces",
description:
"List the user's saved workspace layouts (named sets of open tabs and splits).",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await createCurrentWorkspaceRepository().listByUser(
context.userId,
);
return {
workspaces: workspaces.map((workspace: any) => ({
id: workspace.id,
name: workspace.name,
isDefault: workspace.isDefault ?? false,
})),
};
},
},
{
name: "list_alert_rules",
description:
"List the user's alert rules with their thresholds and enabled state.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const rules = await createCurrentAlertRepository().listAlertRules(
context.userId,
);
return {
rules: rules.map((rule) => ({
id: rule.id,
name: rule.name,
hostId: rule.host_id,
enabled: rule.enabled === 1,
triggerType: rule.trigger_type,
thresholdValue: rule.threshold_value,
thresholdDurationSeconds: rule.threshold_duration_seconds,
cooldownMinutes: rule.cooldown_minutes,
})),
};
},
},
{
name: "list_notification_channels",
description:
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentAlertRepository().listNotificationChannels(
context.userId,
);
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled === 1,
})),
};
},
},
{
name: "get_alert_firings",
description:
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
category: "read",
parameters: objectSchema({
limit: num("How many firings to return (default 25, max 100)"),
}),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const result = await createCurrentAlertRepository().listAlertFirings({
userId: context.userId,
limit,
offset: 0,
});
return {
firings: (result.firings ?? []).map((firing) => ({
id: firing.id,
ruleName: firing.rule_name,
hostName: firing.host_name,
firedAt: firing.fired_at,
resolvedAt: firing.resolved_at,
severity: firing.severity,
message: firing.message,
acknowledged: firing.acknowledged === 1,
})),
};
},
},
{
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const items = await createCurrentHomepageItemRepository().listByUserId(
context.userId,
);
return {
items: (items as any[]).map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
})),
};
},
},
{
name: "get_command_history",
description:
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
category: "read",
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
limit: num("How many entries to return (default 25, max 100)"),
},
["hostId"],
),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const hostId = Number(args.hostId);
// Ownership is enforced here rather than trusted from the model.
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
const commands =
await createCurrentCommandHistoryRepository().listCommandsForHost(
context.userId,
hostId,
limit,
);
return { commands };
},
},
{
name: "get_network_topology",
description: "The user's saved network topology graph, if they have one.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const topology =
await createCurrentNetworkTopologyRepository().findByUserId(
context.userId,
);
if (!topology) return { topology: null };
return { topology: (topology as any).data ?? null };
},
},
];
+72
View File
@@ -0,0 +1,72 @@
export type ToolCategory = "read" | "propose";
export interface ToolContext {
/** Always taken from the verified JWT, never from model input. */
userId: string;
conversationId: number;
/** Per-user opt-in for running allowlisted read-only commands. */
allowReadOnlyCommands: boolean;
}
export interface AiTool {
name: string;
description: string;
category: ToolCategory;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
* Read tools return data to feed back to the model. Propose tools return a
* ProposalDraft and must not mutate anything.
*/
handler: (
args: Record<string, unknown>,
context: ToolContext,
) => Promise<unknown>;
}
/** What a provider adapter needs to describe a tool to its model. */
export interface ToolDefinitionShape {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ProposalDraft {
__proposal: true;
kind: string;
summary: string;
payload: Record<string, unknown>;
}
export function isProposalDraft(value: unknown): value is ProposalDraft {
return (
typeof value === "object" &&
value !== null &&
(value as ProposalDraft).__proposal === true
);
}
export function proposal(
kind: string,
summary: string,
payload: Record<string, unknown>,
): ProposalDraft {
return { __proposal: true, kind, summary, payload };
}
/** Small helper so tool schemas stay readable. */
export function objectSchema(
properties: Record<string, unknown>,
required: string[] = [],
): Record<string, unknown> {
return {
type: "object",
properties,
required,
additionalProperties: false,
};
}
export const str = (description: string) => ({ type: "string", description });
export const num = (description: string) => ({ type: "number", description });
export const bool = (description: string) => ({ type: "boolean", description });
+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": []
}