feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

+57 -41
View File
@@ -1,20 +1,20 @@
import { isIP } from "net";
import { isBlockedAddress } from "../../../../src/backend/utils/safe-outbound-fetch.js";
import { createCurrentSettingsRepository } from "../../../../src/backend/database/repositories/factory.js";
import { BlockList, isIP } from "net";
/**
* 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.
* Every provider request goes through ctx.fetch, which refuses private and
* loopback addresses (after DNS, too) unless the exact host is listed. That
* guard is exactly what a self-hosted Ollama on localhost trips over, so the
* admin setting "privateEndpoints" names the hosts that may be private.
* 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.
*
* The checks here only exist to give a useful message before the request is
* made. ctx.fetch is the control.
*/
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",
@@ -23,35 +23,53 @@ export const DEFAULT_PRIVATE_ALLOWLIST = [
"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 const PRIVATE_DESTINATION_MESSAGE =
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.";
/** A bare host, not a URL: no scheme, path, port or whitespace. */
const HOST_PATTERN = /^[a-z0-9._:-]+$/;
/**
* The admin setting is one host per line. Anything that is not a bare host
* is ignored rather than failing the whole list.
*/
export function parseAllowlist(raw: unknown): string[] {
if (typeof raw !== "string") return [...DEFAULT_PRIVATE_ALLOWLIST];
const hosts: string[] = [];
for (const line of raw.split(/[\r\n,]+/)) {
const host = line.trim().toLowerCase();
if (host && HOST_PATTERN.test(host) && !hosts.includes(host)) {
hosts.push(host);
}
}
return hosts;
}
export async function readPrivateAllowlist(): Promise<string[]> {
const raw = await createCurrentSettingsRepository().get(
AI_PRIVATE_ALLOWLIST_KEY,
);
return parseAllowlist(raw);
}
const PRIVATE_RANGES = (() => {
const list = new BlockList();
list.addSubnet("0.0.0.0", 8, "ipv4");
list.addSubnet("10.0.0.0", 8, "ipv4");
list.addSubnet("100.64.0.0", 10, "ipv4");
list.addSubnet("127.0.0.0", 8, "ipv4");
list.addSubnet("169.254.0.0", 16, "ipv4");
list.addSubnet("172.16.0.0", 12, "ipv4");
list.addSubnet("192.168.0.0", 16, "ipv4");
list.addAddress("::", "ipv6");
list.addAddress("::1", "ipv6");
list.addSubnet("fc00::", 7, "ipv6");
list.addSubnet("fe80::", 10, "ipv6");
return list;
})();
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.
* True when the URL names a destination the outbound guard would refuse. A
* bare hostname that is not an IP literal (e.g. "ollama.internal") counts as
* private only if it is "localhost"; anything else needs DNS resolution,
* which ctx.fetch does.
*/
export function isPrivateDestination(rawUrl: string): boolean {
let url: URL;
@@ -62,13 +80,15 @@ export function isPrivateDestination(rawUrl: string): boolean {
}
const host = normalizeHost(url.hostname);
if (host === "localhost" || host.endsWith(".localhost")) return true;
if (isIP(host)) return isBlockedAddress(host);
const family = isIP(host);
if (family === 4) return PRIVATE_RANGES.check(host, "ipv4");
if (family === 6) return PRIVATE_RANGES.check(host, "ipv6");
return false;
}
export interface EgressDecision {
allowed: boolean;
/** True when the destination needs the allowlisted-private path. */
/** True when the destination is an allowlisted private host. */
isPrivate: boolean;
reason?: string;
}
@@ -96,23 +116,19 @@ export function evaluateEgress(
}
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.
// An allowlisted hostname may resolve to a private address; ctx.fetch
// lets it through because the same list is passed as allowPrivateHosts.
if (normalized.includes(host)) {
return { allowed: true, isPrivate: true };
}
if (!isPrivate) return { allowed: true, isPrivate: false };
if (!isPrivateDestination(rawUrl)) 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.",
reason: PRIVATE_DESTINATION_MESSAGE,
};
}
+15 -7
View File
@@ -1,4 +1,4 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import { getErrorMessage } from "./errors.js";
import { getAdapter } from "./providers/registry.js";
import type {
ChatMessage,
@@ -6,9 +6,10 @@ import type {
ToolCall,
} from "./providers/types.js";
import { redact, redactToJson } from "./redaction.js";
import { getTool, toolDefinitions } from "./tools/catalog.js";
import { toolDefinitions } from "./tools/catalog.js";
import {
isProposalDraft,
type AiTool,
type ProposalDraft,
type ToolContext,
} from "./tools/types.js";
@@ -36,6 +37,8 @@ export interface EngineOptions {
system: string;
history: ChatMessage[];
context: ToolContext;
/** What the model may call this turn; availableTools() decides. */
tools: AiTool[];
signal?: AbortSignal;
}
@@ -43,7 +46,8 @@ export async function* runAgent(
options: EngineOptions,
): AsyncGenerator<EngineEvent> {
const adapter = getAdapter(options.config.providerType);
const tools = toolDefinitions();
const tools = toolDefinitions(options.tools);
const byName = new Map(options.tools.map((tool) => [tool.name, tool]));
const messages: ChatMessage[] = [...options.history];
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
@@ -89,7 +93,7 @@ export async function* runAgent(
for (const call of calls) {
yield { type: "tool_call", name: call.name, arguments: call.arguments };
const result = await runTool(call, options.context);
const result = await runTool(byName, call, options.context);
if (isProposalDraft(result)) {
// Closes the tool call before the proposal card is emitted. Without
@@ -132,10 +136,14 @@ export async function* runAgent(
};
}
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
const tool = getTool(call.name);
async function runTool(
tools: Map<string, AiTool>,
call: ToolCall,
context: ToolContext,
): Promise<unknown> {
const tool = tools.get(call.name);
// A model can emit any name it likes; only the catalog decides what runs.
// A model can emit any name it likes; only what was offered runs.
if (!tool) {
return { error: `Unknown tool: ${call.name}` };
}
+6
View File
@@ -0,0 +1,6 @@
/** The message of an error, or the fallback when there is none worth showing. */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return fallback;
}
+58 -38
View File
@@ -1,28 +1,30 @@
import type { NextFunction, Response } from "express";
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
import {
createCurrentSettingsRepository,
createCurrentUserPreferenceRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import type { PluginSettings } from "@termix/plugin-sdk/backend";
import { DEFAULT_PRIVATE_ALLOWLIST, parseAllowlist } from "./egress.js";
/**
* Three gates, all checked on the server.
*
* The admin global is a hard kill switch: when it is off the feature does not
* The admin switch 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 session sharing's own check, where the global also wins over the
* per-host setting.
* false so upgrading an existing install turns nothing on by surprise. Then
* each user opts in for themselves, and RBAC decides what they may do.
*/
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
export type SettingsReader = Pick<PluginSettings, "get" | "getUser">;
export async function isAiGloballyEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean(
AI_GLOBAL_ENABLED_KEY,
false,
);
export async function isAiGloballyEnabled(
settings: SettingsReader,
): Promise<boolean> {
return (await settings.get<boolean>("globallyEnabled")) === true;
}
/** The admin's private endpoint hosts, one per line. */
export async function readPrivateAllowlist(
settings: SettingsReader,
): Promise<string[]> {
const raw = await settings.get<string>("privateEndpoints");
if (raw === undefined || raw === null) return [...DEFAULT_PRIVATE_ALLOWLIST];
return parseAllowlist(raw);
}
export interface AiAccess {
@@ -30,41 +32,59 @@ export interface AiAccess {
allowReadOnlyCommands: boolean;
}
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
const globalEnabled = await isAiGloballyEnabled();
if (!globalEnabled) {
export async function resolveAiAccess(
settings: SettingsReader,
userId: string,
): Promise<AiAccess> {
if (!(await isAiGloballyEnabled(settings))) {
return { enabled: false, allowReadOnlyCommands: false };
}
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
// Unset means the user was never asked, which is not consent.
const enabled = (await settings.getUser<boolean>(userId, "enabled")) === true;
return {
// Null means the user was never asked, which is not consent.
enabled: preferences?.aiAssistantEnabled === true,
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
enabled,
allowReadOnlyCommands:
enabled &&
(await settings.getUser<boolean>(userId, "allowReadOnlyCommands")) ===
true,
};
}
interface GateRequest {
aiAccess?: AiAccess;
}
interface GateResponse {
status: (code: number) => { json: (body: unknown) => unknown };
}
/** Rejects any AI request unless both gates are open. */
export function createAiGate() {
export function createAiGate(
settings: SettingsReader,
currentActor: () => string | undefined,
) {
return async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction,
req: GateRequest,
res: GateResponse,
next: (error?: unknown) => void,
): Promise<void> => {
if (!req.userId) {
const userId = currentActor();
if (!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;
try {
const access = await resolveAiAccess(settings, userId);
if (!access.enabled) {
res.status(403).json({ error: "The AI assistant is not enabled" });
return;
}
req.aiAccess = access;
next();
} catch (error) {
next(error);
}
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
next();
};
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Registered as "ai.hostImportNormalizer" so core's host import can carry the
* assistant switch without knowing it exists. Reads a Termix export's
* pluginSettings.ai first, then the flat field hosts had before 2.9.0.
*/
export function hostImportNormalizer(
raw: Record<string, unknown>,
): Record<string, unknown> | null {
const pluginSettings = raw.pluginSettings as
Record<string, Record<string, unknown> | undefined> | undefined;
const value = pluginSettings?.ai?.enableAiAssistant ?? raw.enableAiAssistant;
if (value === undefined || value === null) return null;
return {
enableAiAssistant: value === true || value === 1 || value === "true",
};
}
+28 -28
View File
@@ -1,38 +1,38 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./services.js";
import { startAiService, stopAiService } from "./routes.js";
import { createCurrentAiRepository } from "../../../../src/backend/database/repositories/factory.js";
import { hostImportNormalizer } from "./host-import.js";
import { createAiRepository } from "./repository.js";
import { registerAiRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
ctx.disposables.add(() => setPluginSsh(null));
setPluginServices(ctx.services);
ctx.disposables.add(() => setPluginServices(null));
startAiService(ctx.http.router());
const repository = await createAiRepository(ctx);
registerAiRoutes(ctx.http.router<Router>(), repository, ctx);
ctx.registry.provide("ai.hostImportNormalizer", hostImportNormalizer);
ctx.disposables.add(
() =>
void ctx.registry.revoke("ai.hostImportNormalizer", hostImportNormalizer),
);
// Offered by reference, not copied: this hands over a resolver, so the key
// stays ours. Rotating or deleting the provider takes effect on the next
// read by any borrower, and deactivate() below withdraws the offer entirely.
// The resolve is per-user because the keys are stored per-user and encrypted
// under that user's data key, so a borrowed read returns the acting user's
// own default key, never someone else's.
ctx.secrets.offer("api-key", async (userId: string) => {
const repository = createCurrentAiRepository();
const providers = await repository.listProviders(userId);
const preferred = providers.find((provider) => provider.apiKeyPrefix);
if (!preferred) return null;
const withSecret = await repository.findProviderWithSecret(
preferred.id,
userId,
);
return withSecret?.apiKey ?? null;
});
// read by any borrower, and deactivate withdraws the offer. Keys are per
// user, so a borrowed read returns the acting user's own key.
ctx.secrets.offer("api-key", (userId: string) =>
ctx.asUser(userId, async () => {
const providers = await repository.listProviders(userId);
const preferred = providers.find((provider) => provider.apiKeyPrefix);
if (!preferred) return null;
const withSecret = await repository.findProviderWithSecret(
preferred.id,
userId,
);
return withSecret?.apiKey ?? null;
}),
);
ctx.log.info("AI assistant routes mounted at /plugin-api/ai");
}
export async function deactivate() {
stopAiService();
}
export async function deactivate() {}
@@ -1,5 +1,4 @@
import Anthropic from "@anthropic-ai/sdk";
import { providerFetch } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -26,7 +25,7 @@ function createClient(config: ProviderConfig): 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,
fetch: config.fetch as unknown as typeof fetch,
});
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, providerFetch, readSseLines } from "./http.js";
import { assertOk, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -104,7 +104,7 @@ export const geminiAdapter: ProviderAdapter = {
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
const response = await providerFetch(url, {
const response = await config.fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -176,7 +176,7 @@ export const geminiAdapter: ProviderAdapter = {
async listModels(config: ProviderConfig): Promise<string[]> {
if (!config.apiKey) return [];
const response = await providerFetch(
const response = await config.fetch(
`${baseFor(config).replace(/\/+$/, "")}/models`,
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
);
+54 -24
View File
@@ -1,33 +1,63 @@
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";
import type { PluginFetch } from "@termix/plugin-sdk/backend";
import { evaluateEgress, PRIVATE_DESTINATION_MESSAGE } from "../egress.js";
import { AiProviderError, type ProviderFetch } from "./types.js";
/** A model can take a while to load before the first token. */
const PROVIDER_TIMEOUT_MS = 10 * 60 * 1000;
function toHeaderRecord(
headers: RequestInit["headers"] | undefined,
): Record<string, string> {
const record: Record<string, string> = {};
new Headers(headers ?? {}).forEach((value, key) => {
record[key] = value;
});
return record;
}
/**
* 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.
* be bypassed by an adapter calling fetch directly. The actual request is
* ctx.fetch, which pins DNS and refuses private addresses unless the host is
* on the admin allowlist.
*/
export async function providerFetch(
url: string,
init: RequestInit,
): Promise<Response> {
const allowlist = await readPrivateAllowlist();
const decision = evaluateEgress(url, allowlist);
export function createProviderFetch(
fetch: PluginFetch,
allowlist: string[],
): ProviderFetch {
return async (url, init) => {
const decision = evaluateEgress(url, allowlist);
if (!decision.allowed) {
throw new AiProviderError(decision.reason ?? "Destination not allowed");
}
if (!decision.allowed) {
throw new AiProviderError(decision.reason ?? "Destination not allowed");
}
if (
init.body !== undefined &&
init.body !== null &&
typeof init.body !== "string"
) {
throw new AiProviderError("Provider requests must send a text body");
}
if (decision.isPrivate) {
return fetchWithProxy(url, init);
}
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
try {
return await fetch(url, {
method: init.method ?? "GET",
headers: toHeaderRecord(init.headers),
body: (init.body as string | null | undefined) ?? undefined,
signal: init.signal ?? undefined,
timeoutMs: PROVIDER_TIMEOUT_MS,
allowPrivateHosts: allowlist,
});
} catch (error) {
// A hostname that resolved to a private address is refused after DNS,
// which the check above cannot see coming.
const message = error instanceof Error ? error.message : "";
if (/private destinations/i.test(message)) {
throw new AiProviderError(PRIVATE_DESTINATION_MESSAGE);
}
throw error;
}
};
}
export function joinUrl(base: string, path: string): string {
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
import { assertOk, joinUrl, readJsonLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -47,7 +47,7 @@ export const ollamaAdapter: ProviderAdapter = {
config: ProviderConfig,
request: ChatRequest,
): AsyncIterable<ChatChunk> {
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
const response = await config.fetch(joinUrl(baseFor(config), "api/chat"), {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: request.signal,
@@ -117,7 +117,7 @@ export const ollamaAdapter: ProviderAdapter = {
},
async listModels(config: ProviderConfig): Promise<string[]> {
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
const response = await config.fetch(joinUrl(baseFor(config), "api/tags"), {
method: "GET",
});
await assertOk(response, "Ollama");
+3 -3
View File
@@ -1,4 +1,4 @@
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
import { assertOk, joinUrl, readSseLines } from "./http.js";
import type {
ChatChunk,
ChatRequest,
@@ -73,7 +73,7 @@ export const openAiAdapter: ProviderAdapter = {
};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(url, {
const response = await config.fetch(url, {
method: "POST",
headers,
signal: request.signal,
@@ -145,7 +145,7 @@ export const openAiAdapter: ProviderAdapter = {
const headers: Record<string, string> = {};
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
const response = await config.fetch(joinUrl(baseFor(config), "models"), {
method: "GET",
headers,
});
+10
View File
@@ -63,10 +63,20 @@ export type ChatChunk =
| { type: "done"; stopReason?: string }
| { type: "error"; message: string };
/**
* How an adapter reaches its provider. Always the egress-checked fetch from
* http.ts, never the global one.
*/
export type ProviderFetch = (
url: string,
init: RequestInit,
) => Promise<Response>;
export interface ProviderConfig {
providerType: AiProviderType;
baseUrl?: string | null;
apiKey?: string | null;
fetch: ProviderFetch;
}
export interface ProviderAdapter {
+421
View File
@@ -0,0 +1,421 @@
import { and, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
conversations as conversationsDef,
messages as messagesDef,
proposals as proposalsDef,
providers as providersDef,
} from "./tables.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// Tables come from ctx.db.define, which the SDK hands back untyped.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface AiProviderRecord {
id: number;
userId: string;
providerType: string;
label: string;
baseUrl: string | null;
apiKeyPrefix: string | null;
defaultModel: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
/** A provider with its key, for the one path that makes outbound calls. */
export interface AiProviderWithSecret extends AiProviderRecord {
apiKey: string | null;
}
export interface AiConversationRecord {
id: number;
userId: string;
title: string | null;
providerId: number | null;
model: string | null;
createdAt: string;
updatedAt: string;
}
export interface AiMessageRecord {
id: number;
conversationId: number;
role: string;
content: string;
toolCalls: string | null;
createdAt: string;
}
export interface AiProposalRecord {
id: number;
conversationId: number;
userId: string;
kind: string;
summary: string | null;
payload: string;
status: string;
appliedAt: string | null;
resultSummary: string | null;
createdAt: string;
}
export interface AiProviderInput {
providerType: string;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
enabled?: boolean;
}
/** Where a provider's key lives in ctx.secrets. */
export function providerSecretKey(providerId: number): string {
return `provider:${providerId}`;
}
/**
* Keeps the first few characters so the UI can tell two keys apart without
* ever receiving the key itself.
*/
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
if (!apiKey) return null;
return apiKey.slice(0, 6);
}
const now = () => new Date().toISOString();
export type AiRepository = Awaited<ReturnType<typeof createAiRepository>>;
/**
* The assistant's rows. Every method takes the owning user and scopes to
* them. Keys go through ctx.secrets, which is per acting user, so key reads
* and writes run inside the request (or ctx.asUser) of that same user.
*/
export async function createAiRepository(
ctx: Pick<PluginContext, "db" | "secrets">,
) {
const { db, secrets } = ctx;
const providers: Table = await db.define(providersDef);
const conversations: Table = await db.define(conversationsDef);
const messages: Table = await db.define(messagesDef);
const proposals: Table = await db.define(proposalsDef);
const client = () => db.client<Drizzle>();
/** Inserts one row and returns its id. MySQL has no RETURNING. */
async function insertId(
table: Table,
values: Record<string, unknown>,
): Promise<number> {
const drizzle = await client();
if (db.dialect === "mysql") {
const result = await drizzle.insert(table).values(values);
const header = Array.isArray(result) ? result[0] : result;
return Number(header?.insertId);
}
const rows = await drizzle
.insert(table)
.values(values)
.returning({ id: table.id });
return rows[0].id as number;
}
async function first<T>(table: Table, where: unknown): Promise<T | null> {
const rows = await (
await client()
)
.select()
.from(table)
.where(where)
.limit(1);
return (rows[0] as T) ?? null;
}
const repo = {
// --- providers ---
/** Never includes key material; callers get the masked prefix only. */
async listProviders(userId: string): Promise<AiProviderRecord[]> {
return (await client())
.select()
.from(providers)
.where(eq(providers.userId, userId))
.orderBy(providers.id);
},
async findProvider(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
return first<AiProviderRecord>(
providers,
and(eq(providers.id, id), eq(providers.userId, userId)),
);
},
/**
* The one path that returns usable key material, right before an
* outbound request. Must run as `userId`.
*/
async findProviderWithSecret(
id: number,
userId: string,
): Promise<AiProviderWithSecret | null> {
const row = await repo.findProvider(id, userId);
if (!row) return null;
return { ...row, apiKey: await secrets.get(providerSecretKey(id)) };
},
async createProvider(
userId: string,
input: AiProviderInput,
): Promise<AiProviderRecord> {
const stamp = now();
const id = await insertId(providers, {
userId,
providerType: input.providerType,
label: input.label,
baseUrl: input.baseUrl ?? null,
apiKeyPrefix: apiKeyPrefix(input.apiKey),
defaultModel: input.defaultModel ?? null,
enabled: input.enabled ?? true,
createdAt: stamp,
updatedAt: stamp,
});
if (input.apiKey) {
await secrets.set(providerSecretKey(id), input.apiKey);
}
await db.persist();
const created = await repo.findProvider(id, userId);
if (!created) throw new Error("Provider could not be read back");
return created;
},
async updateProvider(
id: number,
userId: string,
input: Partial<AiProviderInput>,
): Promise<AiProviderRecord | null> {
const existing = await repo.findProvider(id, userId);
if (!existing) return null;
const updates: Record<string, unknown> = { updatedAt: now() };
if (input.providerType !== undefined)
updates.providerType = input.providerType;
if (input.label !== undefined) updates.label = input.label;
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
if (input.defaultModel !== undefined)
updates.defaultModel = input.defaultModel;
if (input.enabled !== undefined) updates.enabled = input.enabled;
// An empty string clears the key; undefined leaves it untouched.
if (input.apiKey !== undefined) {
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
await secrets.set(providerSecretKey(id), input.apiKey || null);
}
await (
await client()
)
.update(providers)
.set(updates)
.where(and(eq(providers.id, id), eq(providers.userId, userId)));
await db.persist();
return repo.findProvider(id, userId);
},
async deleteProvider(id: number, userId: string): Promise<boolean> {
const existing = await repo.findProvider(id, userId);
if (!existing) return false;
await (
await client()
)
.delete(providers)
.where(and(eq(providers.id, id), eq(providers.userId, userId)));
await secrets.delete(providerSecretKey(id));
await db.persist();
return true;
},
// --- conversations ---
async listConversations(
userId: string,
limit = 50,
): Promise<AiConversationRecord[]> {
return (await client())
.select()
.from(conversations)
.where(eq(conversations.userId, userId))
.orderBy(desc(conversations.updatedAt))
.limit(limit);
},
async findConversation(
id: number,
userId: string,
): Promise<AiConversationRecord | null> {
return first<AiConversationRecord>(
conversations,
and(eq(conversations.id, id), eq(conversations.userId, userId)),
);
},
async createConversation(input: {
userId: string;
title?: string | null;
providerId?: number | null;
model?: string | null;
}): Promise<AiConversationRecord> {
const stamp = now();
const id = await insertId(conversations, {
userId: input.userId,
title: input.title ?? null,
providerId: input.providerId ?? null,
model: input.model ?? null,
createdAt: stamp,
updatedAt: stamp,
});
await db.persist();
const created = await repo.findConversation(id, input.userId);
if (!created) throw new Error("Conversation could not be read back");
return created;
},
async touchConversation(id: number): Promise<void> {
await (
await client()
)
.update(conversations)
.set({ updatedAt: now() })
.where(eq(conversations.id, id));
await db.persist();
},
async deleteConversation(id: number, userId: string): Promise<boolean> {
const existing = await repo.findConversation(id, userId);
if (!existing) return false;
// Deleted explicitly rather than trusting the cascade, which a
// connection without foreign keys on would skip.
const drizzle = await client();
await drizzle.delete(messages).where(eq(messages.conversationId, id));
await drizzle.delete(proposals).where(eq(proposals.conversationId, id));
await drizzle
.delete(conversations)
.where(and(eq(conversations.id, id), eq(conversations.userId, userId)));
await db.persist();
return true;
},
// --- messages ---
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
return (await client())
.select()
.from(messages)
.where(eq(messages.conversationId, conversationId))
.orderBy(messages.id);
},
async appendMessage(input: {
conversationId: number;
role: string;
content: string;
toolCalls?: string | null;
}): Promise<void> {
await insertId(messages, {
conversationId: input.conversationId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
createdAt: now(),
});
await db.persist();
},
// --- proposals ---
async listProposals(
userId: string,
conversationId: number,
): Promise<AiProposalRecord[]> {
return (await client())
.select()
.from(proposals)
.where(
and(
eq(proposals.userId, userId),
eq(proposals.conversationId, conversationId),
),
)
.orderBy(desc(proposals.id));
},
async findProposal(
id: number,
userId: string,
): Promise<AiProposalRecord | null> {
return first<AiProposalRecord>(
proposals,
and(eq(proposals.id, id), eq(proposals.userId, userId)),
);
},
async createProposal(input: {
conversationId: number;
userId: string;
kind: string;
summary?: string | null;
payload: string;
}): Promise<AiProposalRecord> {
const id = await insertId(proposals, {
conversationId: input.conversationId,
userId: input.userId,
kind: input.kind,
summary: input.summary ?? null,
payload: input.payload,
status: "pending",
createdAt: now(),
});
await db.persist();
const created = await repo.findProposal(id, input.userId);
if (!created) throw new Error("Proposal could not be read back");
return created;
},
/** Only moves a pending proposal. False when it was not pending. */
async setProposalStatus(
id: number,
userId: string,
status: "applied" | "rejected" | "expired",
resultSummary?: string | null,
): Promise<boolean> {
const existing = await repo.findProposal(id, userId);
if (!existing || existing.status !== "pending") return false;
await (
await client()
)
.update(proposals)
.set({
status,
appliedAt: status === "applied" ? now() : null,
resultSummary: resultSummary ?? null,
})
.where(
and(
eq(proposals.id, id),
eq(proposals.userId, userId),
eq(proposals.status, "pending"),
),
);
await db.persist();
return true;
},
};
return repo;
}
File diff suppressed because it is too large. Load diff
+81 -249
View File
@@ -1,111 +1,35 @@
import type { PluginServices } from "@termix/plugin-sdk/backend";
let current: PluginServices | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginServices(services: PluginServices | null): void {
current = services;
}
/** Another plugin's workspaces, as ctx.services.get("workspaces.saved") returns them. */
interface SavedWorkspaces {
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
/**
* The user's saved workspaces, or null when the workspaces plugin is off or
* the user may not use it. Optional: the assistant works without it.
* Other plugins the assistant reads and changes through, each optional. A
* service call runs as the acting user and the provider checks that user's
* permissions, so nothing here takes a user id.
*/
export async function listSavedWorkspaces(
userId: string,
): Promise<Array<{ id: number; name: string; isDefault: boolean }> | null> {
if (!current) return null;
export const SERVICE = {
snippets: "snippets.access",
fleets: "fleets.access",
automations: "automations.access",
workspaces: "workspaces.saved",
topology: "network-topology.graph",
history: "terminal.history",
homepage: "homepage.items",
} as const;
export type ServiceName = (typeof SERVICE)[keyof typeof SERVICE];
/** Whether a compatible provider is running right now. */
export function serviceAvailable(
services: Pick<PluginServices, "providers">,
service: string,
): boolean {
try {
return await current
.get<SavedWorkspaces>("workspaces.saved", { userId })
.list();
return services.providers(service).length > 0;
} catch {
return null;
return false;
}
}
/** Another plugin's graph, as ctx.services.get("network-topology.graph") returns it. */
interface NetworkTopologyGraph {
get: () => Promise<unknown | null>;
}
/**
* The user's saved network topology, or null when the network-topology
* plugin is off or the user may not use it. Optional: the tool disappears
* without it.
*/
export async function getNetworkTopology(
userId: string,
): Promise<unknown | null> {
if (!current) return null;
try {
return await current
.get<NetworkTopologyGraph>("network-topology.graph", { userId })
.get();
} catch {
return null;
}
}
/** Another plugin's fleets, as ctx.services.get("fleets.access") returns them. */
interface FleetsAccess {
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
create: (input: {
name: string;
description?: string | null;
}) => Promise<{ id: number; name: string }>;
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
/**
* The user's fleets, or null when the fleets plugin is off or the user may
* not use it. Optional: the list_fleets tool disappears without it.
*/
export async function listFleets(
userId: string,
): Promise<Array<{ id: number; name: string; color: string | null }> | null> {
if (!current) return null;
try {
return await current.get<FleetsAccess>("fleets.access", { userId }).list();
} catch {
return null;
}
}
/**
* Requires the fleets plugin, unlike listFleets above: a proposal apply must
* surface a failure as an error the user sees, not silently no-op.
*/
function requireFleetsAccess(userId: string): FleetsAccess {
if (!current) {
throw new Error("The fleets plugin is disabled");
}
return current.get<FleetsAccess>("fleets.access", { userId });
}
export async function createFleet(
userId: string,
input: { name: string; description?: string | null },
): Promise<{ id: number; name: string } | null> {
if (!current) return null;
return requireFleetsAccess(userId).create(input);
}
export async function addFleetMember(
userId: string,
fleetId: number,
hostId: number,
): Promise<void> {
await requireFleetsAccess(userId).addMember(fleetId, hostId);
}
export interface SnippetSummary {
id: number;
name: string;
@@ -115,8 +39,7 @@ export interface SnippetSummary {
folder: string | null;
}
/** Another plugin's snippets, as ctx.services.get("snippets.access") returns them. */
interface SnippetsAccess {
export interface SnippetsAccess {
list: () => Promise<SnippetSummary[]>;
get: (id: number) => Promise<{
id: number;
@@ -142,115 +65,18 @@ interface SnippetsAccess {
remove: (id: number) => Promise<boolean>;
}
/**
* The user's own snippets, or null when the snippets plugin is off or the
* user may not use it. Optional: the list_snippets tool disappears without
* it, and the propose_*_snippet tools fail with a clear message.
*/
export async function listSnippets(
userId: string,
): Promise<SnippetSummary[] | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.list();
} catch {
return null;
}
}
export async function getSnippet(
userId: string,
id: number,
): Promise<{
id: number;
name: string;
content: string;
isNote: boolean;
} | null> {
if (!current) return null;
try {
return await current
.get<SnippetsAccess>("snippets.access", { userId })
.get(id);
} catch {
return null;
}
}
/**
* Requires the snippets plugin, unlike the read helpers above: a failed
* proposal apply must surface as an error the user sees, not silently no-op.
*/
function requireSnippetsAccess(userId: string): SnippetsAccess {
if (!current) {
throw new Error("The snippets plugin is disabled");
}
return current.get<SnippetsAccess>("snippets.access", { userId });
}
export async function createSnippet(
userId: string,
input: {
export interface FleetsAccess {
list: () => Promise<
Array<{ id: number; name: string; color: string | null }>
>;
create: (input: {
name: string;
content: string;
description?: string | null;
folder?: string | null;
},
): Promise<{ id: number; name: string }> {
return requireSnippetsAccess(userId).create(input);
}) => Promise<{ id: number; name: string }>;
addMember: (fleetId: number, hostId: number) => Promise<void>;
}
export async function updateSnippet(
userId: string,
id: number,
changes: {
name?: string;
content?: string;
description?: string | null;
folder?: string | null;
},
): Promise<void> {
return requireSnippetsAccess(userId).update(id, changes);
}
export async function deleteSnippet(
userId: string,
id: number,
): Promise<boolean> {
return requireSnippetsAccess(userId).remove(id);
}
/** The terminal's command history, as ctx.services.get("terminal.history") returns it. */
interface TerminalHistory {
list: (
hostId: number,
limit?: number,
) => Promise<Array<{ command: string; executedAt: string }>>;
}
/**
* The user's recent commands on one host, or null when the ssh-terminal
* plugin is off or the user may not read its history.
*/
export async function listCommandHistory(
userId: string,
hostId: number,
limit: number,
): Promise<Array<{ command: string; executedAt: string }> | null> {
if (!current) return null;
try {
return await current
.get<TerminalHistory>("terminal.history", { userId })
.list(hostId, limit);
} catch {
return null;
}
}
/** The automations plugin, as ctx.services.get("automations.access") returns it. */
interface AutomationsAccess {
export interface AutomationsAccess {
list: () => Promise<
Array<{
id: number;
@@ -277,55 +103,61 @@ interface AutomationsAccess {
}) => Promise<{ id: number; name: string }>;
}
function automations(userId: string): Partial<AutomationsAccess> {
if (!current) return {};
try {
return current.get<AutomationsAccess>("automations.access", { userId });
} catch {
return {};
}
export interface SavedWorkspaces {
list: () => Promise<Array<{ id: number; name: string; isDefault: boolean }>>;
}
export interface NetworkTopologyGraph {
get: () => Promise<unknown | null>;
}
export interface TerminalHistory {
list: (
hostId: number,
limit?: number,
) => Promise<Array<{ command: string; executedAt: string }>>;
}
/** homepage.items v1, which the homepage plugin provides from B19. */
export interface HomepageItems {
list: () => Promise<
Array<{ id: number; typeId: string; title: string | null }>
>;
}
interface ServiceTypes {
"snippets.access": SnippetsAccess;
"fleets.access": FleetsAccess;
"automations.access": AutomationsAccess;
"workspaces.saved": SavedWorkspaces;
"network-topology.graph": NetworkTopologyGraph;
"terminal.history": TerminalHistory;
"homepage.items": HomepageItems;
}
/**
* The user's automations, or null when the automations plugin is off or the
* user may not use it.
* The service handle. Throws when the provider is missing or the user may
* not use it; a proposal apply wants that error, a read tool catches it.
*/
export async function listAutomations(
userId: string,
): Promise<Awaited<ReturnType<AutomationsAccess["list"]>> | null> {
const list = automations(userId).list;
if (typeof list !== "function") return null;
export function requireService<S extends ServiceName>(
services: Pick<PluginServices, "get" | "providers">,
service: S,
): ServiceTypes[S] {
if (!serviceAvailable(services, service)) {
throw new Error(`The ${service.split(".")[0]} plugin is not available`);
}
return services.get<ServiceTypes[S]>(service);
}
/** A read through a service, or null when it is missing or refused. */
export async function readService<S extends ServiceName, T>(
services: Pick<PluginServices, "get" | "providers">,
service: S,
read: (handle: ServiceTypes[S]) => Promise<T>,
): Promise<T | null> {
try {
return await list();
return await read(requireService(services, service));
} catch {
return null;
}
}
export async function getAutomation(
userId: string,
id: number,
): Promise<Awaited<ReturnType<AutomationsAccess["get"]>> | undefined> {
const get = automations(userId).get;
if (typeof get !== "function") return undefined;
try {
return await get(id);
} catch {
return undefined;
}
}
/**
* Creates an automation through the plugin, which validates it exactly as
* its own route does. Throws when the plugin is off.
*/
export async function createAutomation(
userId: string,
input: Parameters<AutomationsAccess["create"]>[0],
): Promise<{ id: number; name: string }> {
const create = automations(userId).create;
if (typeof create !== "function") {
throw new Error("The automations plugin is not available");
}
return create(input);
}
-36
View File
@@ -1,36 +0,0 @@
import type { Client } from "ssh2";
import type {
PluginSsh,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
+137
View File
@@ -0,0 +1,137 @@
import {
adoptLegacyTable,
boolean,
defineTable,
id,
integer,
refUser,
text,
timestamp,
varchar,
} from "@termix/plugin-sdk/db";
/*
* Adopted from core's ai_* tables, so column and index names are the legacy
* ones and the rename carries every row across.
*
* Messages and proposals point at their conversation with a plain integer
* here, since the SDK only references users and ssh_data. The adoption
* migrations keep that foreign key by hand, so deleting a conversation still
* cascades.
*
* Provider API keys are not in these tables: they live in ctx.secrets under
* "provider:<id>". A 2.8 database still has an api_key column, which the
* boot migration empties after moving the key.
*/
export const providers = adoptLegacyTable(
"ai_providers",
defineTable(
"providers",
{
id: id(),
userId: refUser(),
// ollama | anthropic | openai | gemini | openai_compatible
providerType: text().notNull(),
label: varchar().notNull(),
// Required for ollama and openai_compatible, optional elsewhere.
baseUrl: text(),
// First few characters, kept in the clear so the UI can tell keys apart.
apiKeyPrefix: text(),
defaultModel: text(),
enabled: boolean().notNull().default(true),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
uniques: [
{ name: "idx_ai_providers_user_label", columns: ["userId", "label"] },
],
},
),
);
export const conversations = adoptLegacyTable(
"ai_conversations",
defineTable(
"conversations",
{
id: id(),
userId: refUser(),
title: text(),
providerId: integer(),
model: text(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_ai_conversations_user",
columns: ["userId", "updatedAt"],
},
],
},
),
);
export const messages = adoptLegacyTable(
"ai_messages",
defineTable(
"messages",
{
id: id(),
conversationId: integer().notNull(),
// user | assistant | tool
role: text().notNull(),
content: text().notNull().default(""),
// Serialized tool calls for this turn.
toolCalls: text(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_ai_messages_conversation",
columns: ["conversationId", "createdAt"],
},
],
},
),
);
/**
* A change the assistant wants to make. Nothing here has been applied: the
* payload is re-validated at apply time and only then dispatched.
*/
export const proposals = adoptLegacyTable(
"ai_proposals",
defineTable(
"proposals",
{
id: id(),
conversationId: integer().notNull(),
userId: refUser(),
// The propose_* tool name that produced this.
kind: varchar().notNull(),
summary: text(),
payload: text().notNull().default("{}"),
// pending | applied | rejected | expired
status: varchar(32).notNull().default("pending"),
appliedAt: text(),
resultSummary: text(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_ai_proposals_user", columns: ["userId", "status"] },
{
name: "idx_ai_proposals_conversation",
columns: ["conversationId"],
},
],
},
),
);
export const tables = [providers, conversations, messages, proposals];
+14 -2
View File
@@ -58,9 +58,21 @@ export const FORBIDDEN_DOMAINS = [
"settings",
];
/**
* The tools offered right now. A tool whose service has no running provider
* is left out, so the model never sees something it cannot use.
*/
export function availableTools(
isAvailable: (service: string) => boolean,
): AiTool[] {
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
}
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(): ToolDefinitionShape[] {
return AI_TOOLS.map((tool) => ({
export function toolDefinitions(
tools: AiTool[] = AI_TOOLS,
): ToolDefinitionShape[] {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
+73 -72
View File
@@ -1,17 +1,8 @@
import { createCurrentHostRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../../src/backend/hosts/host-resolver.js";
import type { Client } from "ssh2";
import { execCommand } from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js";
import { getTool } from "./catalog.js";
import {
createSnippet,
updateSnippet,
deleteSnippet,
getSnippet,
createFleet,
addFleetMember,
createAutomation,
} from "../services.js";
import type { ToolDeps } from "./types.js";
import { requireService, SERVICE } from "../services.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
@@ -22,8 +13,8 @@ const COMMAND_TIMEOUT_MS = 60_000;
* 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.
* Everything goes through the same core APIs and plugin services a human
* action uses, and runs as the approving user: the apply request's actor.
*/
export interface ApplyResult {
@@ -52,10 +43,29 @@ function optionalString(value: unknown): string | null {
return trimmed ? trimmed : null;
}
/** Tags as ssh_data stores them, comma-separated. */
function joinTags(value: unknown): string {
if (!Array.isArray(value)) return "";
return value
.filter((tag): tag is string => typeof tag === "string")
.map((tag) => tag.trim().replace(/,/g, ""))
.filter(Boolean)
.join(",");
}
async function requirePermission(
deps: ToolDeps,
permission: string,
): Promise<void> {
if (!(await deps.rbac.has(permission))) {
throw new Error("You do not have permission to do this");
}
}
export async function applyProposal(
kind: string,
payload: Record<string, unknown>,
userId: string,
deps: ToolDeps,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
@@ -64,31 +74,25 @@ export async function applyProposal(
switch (kind) {
case "propose_create_host": {
const created = await createCurrentHostRepository().create({
userId,
await requirePermission(deps, "hosts.create");
const created = await deps.hosts.create({
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(),
};
// Credentials are attached by the user after approving.
authType: "password",
folder: optionalString(payload.folder),
tags: joinTags(payload.tags),
});
return { ok: true, summary: `Created host ${created.name ?? ""}`.trim() };
}
case "propose_update_host": {
await requirePermission(deps, "hosts.edit");
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");
@@ -98,37 +102,32 @@ export async function applyProposal(
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 : [],
);
}
updates.folder = optionalString(changes.folder);
if (changes.tags !== undefined) updates.tags = joinTags(changes.tags);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentHostRepository().updateForUser(
userId,
hostId,
updates as any,
);
// update() refuses a host the approving user does not own.
const updated = await deps.hosts.update(hostId, updates);
if (!updated) throw new Error("Host not found");
return { ok: true, summary: `Updated host ${hostId}` };
}
case "propose_delete_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const deleted = await createCurrentHostRepository().deleteForUser(
userId,
hostId,
);
// delete() checks hosts.delete and ownership itself.
const deleted = await deps.hosts.delete(hostId);
if (!deleted) throw new Error("Host not found");
return { ok: true, summary: `Deleted host ${hostId}` };
}
case "propose_create_snippet": {
const created = await createSnippet(userId, {
const created = await requireService(
deps.services,
SERVICE.snippets,
).create({
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
@@ -144,7 +143,8 @@ export async function applyProposal(
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await getSnippet(userId, snippetId);
const snippets = requireService(deps.services, SERVICE.snippets);
const existing = await snippets.get(snippetId);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
@@ -161,23 +161,26 @@ export async function applyProposal(
return { ok: false, summary: "Nothing to change" };
}
await updateSnippet(userId, snippetId, updates);
await snippets.update(snippetId, updates);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await deleteSnippet(userId, snippetId);
const deleted = await requireService(
deps.services,
SERVICE.snippets,
).remove(snippetId);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
case "propose_create_fleet": {
const fleet = await createFleet(userId, {
const fleets = requireService(deps.services, SERVICE.fleets);
const fleet = await fleets.create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
});
if (!fleet) throw new Error("The fleets plugin is disabled");
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
@@ -185,12 +188,9 @@ export async function applyProposal(
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 addFleetMember(userId, fleet.id, hostId);
const access = await deps.hosts.checkAccess(hostId, "view");
if (!access.isOwner) continue;
await fleets.addMember(fleet.id, hostId);
added += 1;
}
@@ -203,7 +203,10 @@ export async function applyProposal(
case "propose_create_automation": {
// The automations plugin runs the same validator its own route does,
// so an LLM-authored definition is held to exactly the human standard.
const created = await createAutomation(userId, {
const created = await requireService(
deps.services,
SERVICE.automations,
).create({
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: payload.definition,
@@ -222,14 +225,10 @@ export async function applyProposal(
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);
// ctx.ssh resolves the host for the approving user, with the same
// connect-level access check, owner-key decryption and jump chain a
// terminal gets.
const result = await runCommandOnHost(deps, hostId, command);
if (result.error) {
throw new Error(result.error);
}
@@ -245,18 +244,20 @@ export async function applyProposal(
}
/**
* Runs one approved command over the shared SSH pool, mirroring how an
* Runs one approved command over this plugin's SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
host: Record<string, any>,
deps: ToolDeps,
hostId: number,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withSshConnection(
host as any as never,
{ pool: "fleet", purpose: "fleet" },
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
const result = await deps.ssh.withConnection<
Awaited<ReturnType<typeof execCommand>>,
Client
>(hostId, { pool: "ai", purpose: "fleet" }, (client) =>
execCommand(client, command, COMMAND_TIMEOUT_MS),
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
@@ -1,4 +1,5 @@
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
import { SERVICE } from "../services.js";
/**
* Propose tools never mutate anything. They return a draft that is stored as a
@@ -100,6 +101,7 @@ export const proposeTools: AiTool[] = [
description:
"Propose saving a new command snippet the user can run against their hosts.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
name: str("Snippet name"),
@@ -125,6 +127,7 @@ export const proposeTools: AiTool[] = [
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
@@ -151,6 +154,7 @@ export const proposeTools: AiTool[] = [
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
service: SERVICE.snippets,
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
@@ -170,6 +174,7 @@ export const proposeTools: AiTool[] = [
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",
service: SERVICE.automations,
parameters: objectSchema(
{
name: str("Automation name"),
@@ -197,6 +202,7 @@ export const proposeTools: AiTool[] = [
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
service: SERVICE.fleets,
parameters: objectSchema(
{
name: str("Fleet name"),
+79 -78
View File
@@ -1,18 +1,6 @@
import {
createCurrentNotificationChannelRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
} from "../../../../../src/backend/database/repositories/factory.js";
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { num, objectSchema, type AiTool } from "./types.js";
import {
listSavedWorkspaces,
getNetworkTopology,
listSnippets,
listFleets,
listCommandHistory,
listAutomations,
getAutomation,
} from "../services.js";
import { readService, SERVICE } from "../services.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -20,35 +8,24 @@ import {
* 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 splitTags(tags: string | null): string[] {
if (!tags) return [];
return tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
}
function toHostSummary(host: Record<string, any>): HostSummary {
function toHostSummary(host: PluginHostSummary) {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip ?? null,
port: host.port ?? null,
username: host.username ?? null,
ip: host.ip,
port: host.port,
username: host.username,
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,
tags: splitTags(host.tags),
authType: host.authType,
};
}
@@ -60,10 +37,8 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await createCurrentHostRepository().listByUserId(
context.userId,
);
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
const hosts = await context.deps.hosts.list();
return { hosts: hosts.map(toHostSummary) };
},
},
{
@@ -76,40 +51,43 @@ export const readTools: AiTool[] = [
["hostId"],
),
handler: async (args, context) => {
const hostId = Number(args.hostId);
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
const host = await context.deps.hosts.get(Number(args.hostId));
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host as any) };
return { host: toHostSummary(host) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory. Unavailable if the fleets plugin is disabled.",
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
category: "read",
service: SERVICE.fleets,
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await listFleets(context.userId);
const fleets = await readService(
context.deps.services,
SERVICE.fleets,
(fleetsAccess) => fleetsAccess.list(),
);
if (fleets === null) return { unavailable: true, fleets: [] };
return {
fleets: fleets.map((fleet) => ({
id: fleet.id,
name: fleet.name,
})),
fleets: fleets.map((fleet) => ({ id: fleet.id, name: fleet.name })),
};
},
},
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text. Unavailable if the snippets plugin is disabled.",
"List the user's saved command snippets, including their folder and the command text.",
category: "read",
service: SERVICE.snippets,
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await listSnippets(context.userId);
const snippets = await readService(
context.deps.services,
SERVICE.snippets,
(snippetsAccess) => snippetsAccess.list(),
);
if (!snippets) return { unavailable: true, snippets: [] };
return {
snippets: snippets.map((snippet) => ({
@@ -127,9 +105,14 @@ export const readTools: AiTool[] = [
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",
service: SERVICE.automations,
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await listAutomations(context.userId);
const automations = await readService(
context.deps.services,
SERVICE.automations,
(automationsAccess) => automationsAccess.list(),
);
if (!automations) return { automations: [], unavailable: true };
return {
automations: automations.map((automation) => ({
@@ -150,6 +133,7 @@ export const readTools: AiTool[] = [
description:
"Get one automation's full definition (trigger and steps) by id.",
category: "read",
service: SERVICE.automations,
parameters: objectSchema(
{
automationId: num("The automation id, as returned by list_automations"),
@@ -157,11 +141,15 @@ export const readTools: AiTool[] = [
["automationId"],
),
handler: async (args, context) => {
const automation = await getAutomation(
context.userId,
Number(args.automationId),
const result = await readService(
context.deps.services,
SERVICE.automations,
async (automationsAccess) => ({
automation: await automationsAccess.get(Number(args.automationId)),
}),
);
if (automation === undefined) return { unavailable: true };
if (result === null) return { unavailable: true };
const { automation } = result;
if (!automation) return { error: "Automation not found" };
return {
automation: {
@@ -178,9 +166,14 @@ export const readTools: AiTool[] = [
description:
"List the user's saved workspace layouts (named sets of open tabs and splits).",
category: "read",
service: SERVICE.workspaces,
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await listSavedWorkspaces(context.userId);
const workspaces = await readService(
context.deps.services,
SERVICE.workspaces,
(saved) => saved.list(),
);
if (workspaces === null) return { workspaces: [], unavailable: true };
return { workspaces };
},
@@ -192,16 +185,13 @@ export const readTools: AiTool[] = [
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentNotificationChannelRepository().listNotificationChannels(
context.userId,
);
const channels = await context.deps.notify.channels();
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled === 1,
enabled: channel.enabled,
})),
};
},
@@ -210,13 +200,17 @@ export const readTools: AiTool[] = [
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
category: "read",
service: SERVICE.homepage,
parameters: objectSchema({}),
handler: async (_args, context) => {
const items = await createCurrentHomepageItemRepository().listByUserId(
context.userId,
const items = await readService(
context.deps.services,
SERVICE.homepage,
(homepage) => homepage.list(),
);
if (items === null) return { items: [], unavailable: true };
return {
items: (items as any[]).map((item) => ({
items: items.map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
@@ -229,6 +223,7 @@ export const readTools: AiTool[] = [
description:
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
category: "read",
service: SERVICE.history,
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
@@ -240,14 +235,15 @@ export const readTools: AiTool[] = [
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,
);
// Access is checked here rather than trusted from the model.
const host = await context.deps.hosts.get(hostId);
if (!host) return { error: "Host not found" };
const history = await listCommandHistory(context.userId, hostId, limit);
const history = await readService(
context.deps.services,
SERVICE.history,
(terminalHistory) => terminalHistory.list(hostId, limit),
);
if (history === null) return { commands: [], unavailable: true };
return { commands: history.map((entry) => entry.command) };
},
@@ -256,11 +252,16 @@ export const readTools: AiTool[] = [
name: "get_network_topology",
description: "The user's saved network topology graph, if they have one.",
category: "read",
service: SERVICE.topology,
parameters: objectSchema({}),
handler: async (_args, context) => {
const topology = await getNetworkTopology(context.userId);
if (topology === null) return { topology: null, unavailable: true };
return { topology };
const result = await readService(
context.deps.services,
SERVICE.topology,
async (graph) => ({ topology: await graph.get() }),
);
if (result === null) return { topology: null, unavailable: true };
return { topology: result.topology };
},
},
];
+15 -1
View File
@@ -1,17 +1,31 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type ToolCategory = "read" | "propose";
/** What tools and the proposal executor reach core and other plugins through. */
export type ToolDeps = Pick<
PluginContext,
"hosts" | "services" | "notify" | "rbac" | "ssh"
>;
export interface ToolContext {
/** Always taken from the verified JWT, never from model input. */
/** Always the request's authenticated user, never from model input. */
userId: string;
conversationId: number;
/** Per-user opt-in for running allowlisted read-only commands. */
allowReadOnlyCommands: boolean;
deps: ToolDeps;
}
export interface AiTool {
name: string;
description: string;
category: ToolCategory;
/**
* The service this tool needs. While no plugin provides it, the tool is
* not offered to the model at all.
*/
service?: string;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
+3 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { setAssistantEnabledForUser } from "./settings/AiUserSettings";
import { setAiOptIn } from "./ai-api";
/**
* Asks once, plainly.
@@ -17,7 +17,8 @@ export function AiAssistantStep() {
function apply(next: boolean) {
setEnabled(next);
setAssistantEnabledForUser(next);
// The local choice still shows if the write fails.
setAiOptIn(next).catch(() => undefined);
}
// Skipping the step entirely still has to mean off, not unset.
+5 -5
View File
@@ -1,20 +1,20 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Loader2, Send, Settings2, Sparkles, Square } from "lucide-react";
import { Button } from "@/components/button";
import { Textarea } from "@/components/textarea";
import { saveUserPreferences } from "@/main-axios";
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
Textarea,
} from "@termix/plugin-sdk/ui";
import {
getAiConversation,
getAiProviders,
getAiStatus,
setAiOptIn,
type AiProposal,
type AiProvider,
} from "./ai-api";
@@ -133,7 +133,7 @@ export function AiPanel({ activeTab }: { activeTab?: string | null }) {
// 403s through the gate, which surfaces as a bare permission error
// rather than anything the user can act on.
if (status.globallyEnabled && !status.enabled) {
await saveUserPreferences({ aiAssistantEnabled: true });
await setAiOptIn(true);
}
if (!status.globallyEnabled) return;
@@ -1,18 +1,18 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
} from "@termix/plugin-sdk/ui";
import {
createAiProvider,
deleteAiProvider,
+2 -2
View File
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { applyAiProposal, rejectAiProposal, type AiProposal } from "./ai-api";
import { fieldLabel, toolLabel } from "./labels";
+67 -70
View File
@@ -1,4 +1,35 @@
import { authApi, handleApiError } from "@/main-axios";
import { aiApp } from "./app-ref";
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "termix-ai:status-changed";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/** The server's own message when it sent one, so the user sees why. */
function apiError(error: unknown, action: string): Error {
const data = (error as { response?: { data?: { error?: unknown } } })
?.response?.data;
if (typeof data?.error === "string" && data.error) {
return new Error(data.error);
}
if (error instanceof Error && error.message) return error;
return new Error(`Failed to ${action}`);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
// Response bodies are typed per function below, at the edge.
interface LooseClient {
get(url: string): Promise<{ data: any }>;
delete(url: string): Promise<{ data: any }>;
post(url: string, body?: unknown): Promise<{ data: any }>;
put(url: string, body?: unknown): Promise<{ data: any }>;
patch(url: string, body?: unknown): Promise<{ data: any }>;
}
/* eslint-enable @typescript-eslint/no-explicit-any */
const api = () => aiApp().api as unknown as LooseClient;
export type AiProviderType =
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
@@ -52,17 +83,17 @@ export interface AiStatus {
export async function getAiStatus(): Promise<AiStatus> {
try {
return (await authApi.get("/plugin-api/ai/status")).data;
return (await api().get("/status")).data;
} catch (error) {
throw handleApiError(error, "get AI status");
throw apiError(error, "get AI status");
}
}
export async function getAiProviders(): Promise<AiProvider[]> {
try {
return (await authApi.get("/plugin-api/ai/providers")).data.providers;
return (await api().get("/providers")).data.providers;
} catch (error) {
throw handleApiError(error, "list AI providers");
throw apiError(error, "list AI providers");
}
}
@@ -74,10 +105,9 @@ export async function createAiProvider(input: {
defaultModel?: string | null;
}): Promise<AiProvider> {
try {
return (await authApi.post("/plugin-api/ai/providers", input)).data
.provider;
return (await api().post("/providers", input)).data.provider;
} catch (error) {
throw handleApiError(error, "create AI provider");
throw apiError(error, "create AI provider");
}
}
@@ -92,18 +122,17 @@ export async function updateAiProvider(
}>,
): Promise<AiProvider> {
try {
return (await authApi.patch(`/plugin-api/ai/providers/${id}`, input)).data
.provider;
return (await api().patch(`/providers/${id}`, input)).data.provider;
} catch (error) {
throw handleApiError(error, "update AI provider");
throw apiError(error, "update AI provider");
}
}
export async function deleteAiProvider(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/providers/${id}`);
await api().delete(`/providers/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI provider");
throw apiError(error, "delete AI provider");
}
}
@@ -122,27 +151,25 @@ export async function probeAiModels(input: {
warning?: string;
}> {
try {
return (await authApi.post("/plugin-api/ai/probe-models", input)).data;
return (await api().post("/probe-models", input)).data;
} catch (error) {
throw handleApiError(error, "detect models");
throw apiError(error, "detect models");
}
}
export async function getAiProviderModels(id: number): Promise<string[]> {
try {
return (await authApi.get(`/plugin-api/ai/providers/${id}/models`)).data
.models;
return (await api().get(`/providers/${id}/models`)).data.models;
} catch (error) {
throw handleApiError(error, "list provider models");
throw apiError(error, "list provider models");
}
}
export async function getAiConversations(): Promise<AiConversation[]> {
try {
return (await authApi.get("/plugin-api/ai/conversations")).data
.conversations;
return (await api().get("/conversations")).data.conversations;
} catch (error) {
throw handleApiError(error, "list AI conversations");
throw apiError(error, "list AI conversations");
}
}
@@ -152,17 +179,17 @@ export async function getAiConversation(id: number): Promise<{
proposals: AiProposal[];
}> {
try {
return (await authApi.get(`/plugin-api/ai/conversations/${id}`)).data;
return (await api().get(`/conversations/${id}`)).data;
} catch (error) {
throw handleApiError(error, "load AI conversation");
throw apiError(error, "load AI conversation");
}
}
export async function deleteAiConversation(id: number): Promise<void> {
try {
await authApi.delete(`/plugin-api/ai/conversations/${id}`);
await api().delete(`/conversations/${id}`);
} catch (error) {
throw handleApiError(error, "delete AI conversation");
throw apiError(error, "delete AI conversation");
}
}
@@ -170,17 +197,17 @@ export async function applyAiProposal(
id: number,
): Promise<{ success: boolean; summary: string }> {
try {
return (await authApi.post(`/plugin-api/ai/proposals/${id}/apply`)).data;
return (await api().post(`/proposals/${id}/apply`)).data;
} catch (error) {
throw handleApiError(error, "apply AI proposal");
throw apiError(error, "apply AI proposal");
}
}
export async function rejectAiProposal(id: number): Promise<void> {
try {
await authApi.post(`/plugin-api/ai/proposals/${id}/reject`);
await api().post(`/proposals/${id}/reject`);
} catch (error) {
throw handleApiError(error, "reject AI proposal");
throw apiError(error, "reject AI proposal");
}
}
@@ -195,53 +222,23 @@ export async function markAiProposalRunInTerminal(
): Promise<{ success: boolean; summary: string }> {
try {
return (
await authApi.post(
`/plugin-api/ai/proposals/${id}/mark-run-in-terminal`,
{
hostId,
summary,
},
)
await api().post(`/proposals/${id}/mark-run-in-terminal`, {
hostId,
summary,
})
).data;
} catch (error) {
throw handleApiError(error, "mark AI proposal run in terminal");
throw apiError(error, "mark AI proposal run in terminal");
}
}
// --- admin ---
export async function getAiGloballyEnabled(): Promise<boolean> {
/** Turns the assistant on or off for the current user. */
export async function setAiOptIn(enabled: boolean): Promise<void> {
try {
return (await authApi.get("/plugin-api/ai/enabled")).data.enabled;
await api().put("/opt-in", { enabled });
} catch (error) {
throw handleApiError(error, "get AI enabled setting");
}
}
export async function setAiGloballyEnabled(enabled: boolean): Promise<boolean> {
try {
return (await authApi.patch("/plugin-api/ai/enabled", { enabled })).data
.enabled;
} catch (error) {
throw handleApiError(error, "update AI enabled setting");
}
}
export async function getAiPrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/plugin-api/ai/private-endpoints")).data.hosts;
} catch (error) {
throw handleApiError(error, "get AI endpoint allowlist");
}
}
export async function setAiPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (await authApi.patch("/plugin-api/ai/private-endpoints", { hosts }))
.data.hosts;
} catch (error) {
throw handleApiError(error, "update AI endpoint allowlist");
throw apiError(error, "update the AI setting");
} finally {
notifyAiStatusChanged();
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setAiApp(app: TermixApp | null): void {
current = app;
}
export function aiApp(): TermixApp {
if (!current) throw new Error("The AI plugin is not active");
return current;
}
+4
View File
@@ -0,0 +1,4 @@
/** A readable message from anything thrown, or the fallback. */
export function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
+28 -12
View File
@@ -12,11 +12,12 @@ import type {
import { AiPanel } from "./AiPanel";
import { TerminalAiPanel } from "./terminal/TerminalAiPanel";
import { AiAssistantStep } from "./AiAssistantStep";
import { AiAccessSettings } from "./settings/AiAccessSettings";
import { AI_RAIL_ID, AiUserSettings } from "./settings/AiUserSettings";
import { getAiStatus } from "./ai-api";
import { AI_STATUS_CHANGED_EVENT } from "./use-ai-availability";
import { ProvidersSetting } from "./settings/ProvidersSetting";
import { AI_STATUS_CHANGED_EVENT, getAiStatus } from "./ai-api";
import { setAiApp } from "./app-ref";
/** The rail id the assistant registers under. */
const AI_RAIL_ID = "ai";
const ASSISTANT_ACTION = "ai.openWithContext";
const SIDE_PANEL_ID = "ai.assistant";
@@ -57,6 +58,9 @@ function TerminalSidePanel({
}
export function activate(app: TermixApp): void {
setAiApp(app);
app.onDispose(() => setAiApp(null));
let globallyEnabled = false;
let userEnabled = false;
const surface: (() => void)[] = [];
@@ -99,10 +103,14 @@ export function activate(app: TermixApp): void {
}),
);
// The per-host switch is this plugin's host setting.
const offeredOnHost = (context: Record<string, unknown>) =>
userEnabled &&
(context.host as { enableAiAssistant?: boolean } | undefined)
?.enableAiAssistant === true;
(
context.host as
| { pluginSettings?: { ai?: { enableAiAssistant?: boolean } } }
| undefined
)?.pluginSettings?.ai?.enableAiAssistant === true;
surface.push(
app.registerSlotContribution("terminal.toolbar", {
@@ -171,15 +179,23 @@ export function activate(app: TermixApp): void {
{ permission: "services.use" },
);
app.registerSettingsComponent("access", AiAccessSettings);
app.registerSettingsComponent("assistant", AiUserSettings);
app.registerSettingsComponent("providers", ProvidersSetting);
applyStatus();
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.addEventListener("hiddenRailTabsChanged", refresh);
// The admin switch and the user's own choice are settings, saved from
// the settings screen, so re-read when plugin state or focus changes too.
let lastFocusRefresh = Date.now();
const onFocus = () => {
if (Date.now() - lastFocusRefresh < 30_000) return;
lastFocusRefresh = Date.now();
refresh();
};
const events = [AI_STATUS_CHANGED_EVENT, "termix:plugins-changed"];
for (const event of events) window.addEventListener(event, refresh);
window.addEventListener("focus", onFocus);
app.onDispose(() => {
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
for (const event of events) window.removeEventListener(event, refresh);
window.removeEventListener("focus", onFocus);
});
}
@@ -1,95 +0,0 @@
import { useEffect, useState } from "react";
import { useToast, useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, Input, SettingRow } from "@termix/plugin-sdk/ui";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
setAiGloballyEnabled,
setAiPrivateEndpoints,
} from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/**
* The instance-wide switch and the private address allowlist. Stored in the
* assistant's own backend rather than plugin settings, so this saves on
* change instead of through the page's Save.
*/
export function AiAccessSettings() {
const { t } = useTranslation();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [endpoints, setEndpoints] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
void Promise.allSettled([
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
]).then(([enabledResult, endpointsResult]) => {
if (cancelled) return;
if (enabledResult.status === "fulfilled") setEnabled(enabledResult.value);
setEndpoints(
endpointsResult.status === "fulfilled" ? endpointsResult.value : [],
);
});
return () => {
cancelled = true;
};
}, []);
const toggle = async (next: boolean) => {
setEnabled(next);
try {
await setAiGloballyEnabled(next);
notifyAiStatusChanged();
} catch {
setEnabled(!next);
toast.error(t("admin.updateAiEnabledFailed"));
}
};
const saveEndpoints = async (hosts: string[]) => {
const previous = endpoints ?? [];
setEndpoints(hosts);
try {
setEndpoints(await setAiPrivateEndpoints(hosts));
} catch {
setEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
};
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("admin.aiGloballyEnabled")}
description={t("admin.aiGloballyEnabledDesc")}
>
<FakeSwitch checked={enabled} onChange={(next) => void toggle(next)} />
</SettingRow>
{enabled && endpoints !== null && (
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.aiPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.aiPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={endpoints.join(", ")}
placeholder="localhost, 127.0.0.1"
onBlur={(event) =>
void saveEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
)}
</div>
);
}
@@ -1,112 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FakeSwitch, SettingRow } from "@termix/plugin-sdk/ui";
import { saveUserPreferences } from "@/main-axios";
import { readHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { getAiStatus } from "../ai-api";
import { notifyAiStatusChanged } from "../use-ai-availability";
/** The rail id the assistant registers under. */
export const AI_RAIL_ID = "ai";
/**
* Turns the assistant on or off for one person. Off also hides its rail
* entry, so declining removes the feature from view rather than leaving an
* inert button behind. Stored in core user preferences, where it has always
* lived, so it roams with the rest of a user's settings.
*/
export function setAssistantEnabledForUser(enabled: boolean): void {
const hidden = readHiddenRailTabs();
if (enabled) hidden.delete(AI_RAIL_ID);
else hidden.add(AI_RAIL_ID);
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
saveUserPreferences({
aiAssistantEnabled: enabled,
hiddenRailTabs: serialized,
})
.catch(() => {
// The local choice still applies if the write fails.
})
.finally(() => notifyAiStatusChanged());
}
export function AiUserSettings() {
const { t } = useTranslation();
const [status, setStatus] = useState<{
globallyEnabled: boolean;
enabled: boolean;
readOnly: boolean;
} | null>(null);
useEffect(() => {
let cancelled = false;
getAiStatus()
.then((next) => {
if (!cancelled) {
setStatus({
globallyEnabled: next.globallyEnabled,
enabled: next.enabled,
readOnly: next.allowReadOnlyCommands,
});
}
})
.catch(() => {
if (!cancelled) {
setStatus({
globallyEnabled: false,
enabled: false,
readOnly: false,
});
}
});
return () => {
cancelled = true;
};
}, []);
// A user who cannot have the feature is not told about it.
if (!status?.globallyEnabled) {
return (
<p className="text-xs text-muted-foreground">
{t("settings.assistantUnavailable")}
</p>
);
}
return (
<div className="flex flex-col gap-1">
<SettingRow
label={t("ai.enableTitle")}
description={t("ai.enableDescription")}
>
<FakeSwitch
checked={status.enabled}
onChange={(enabled) => {
setStatus({ ...status, enabled });
setAssistantEnabledForUser(enabled);
}}
/>
</SettingRow>
{status.enabled && (
<SettingRow
label={t("ai.readOnlyCommands")}
description={t("ai.readOnlyCommandsDescription")}
>
<FakeSwitch
checked={status.readOnly}
onChange={(readOnly) => {
setStatus({ ...status, readOnly });
saveUserPreferences({ aiReadOnlyCommands: readOnly }).catch(
() => {},
);
}}
/>
</SettingRow>
)}
</div>
);
}
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from "react";
import { useSettings, useTranslation } from "@termix/plugin-sdk/frontend";
import { AiProviderSettings } from "../AiProviderSettings";
import {
AI_STATUS_CHANGED_EVENT,
getAiProviders,
getAiStatus,
type AiProvider,
} from "../ai-api";
/**
* The "providers" custom field in the plugin's user settings: a list with
* per-provider forms, which a schema field cannot express.
*/
export function ProvidersSetting() {
const { t } = useTranslation();
const [status, setStatus] = useState<{
globallyEnabled: boolean;
enabled: boolean;
} | null>(null);
const [providers, setProviders] = useState<AiProvider[]>([]);
// Re-read when the "enabled" switch above this field is saved.
const optedIn = useSettings("user").values.enabled;
const load = useCallback(async () => {
try {
const next = await getAiStatus();
setStatus(next);
if (next.enabled) setProviders(await getAiProviders());
} catch {
setStatus({ globallyEnabled: false, enabled: false });
}
}, []);
useEffect(() => {
void load();
}, [load, optedIn]);
useEffect(() => {
window.addEventListener(AI_STATUS_CHANGED_EVENT, load);
return () => window.removeEventListener(AI_STATUS_CHANGED_EVENT, load);
}, [load]);
if (status === null) return null;
if (!status.enabled) {
return (
<p className="text-xs text-muted-foreground">
{status.globallyEnabled
? t("settings.providersNeedOptIn")
: t("settings.assistantUnavailable")}
</p>
);
}
return (
<AiProviderSettings providers={providers} onChanged={() => void load()} />
);
}
@@ -7,7 +7,7 @@ import {
} from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Bot, GripVertical, Send, Square, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import { getAiProviders, type AiProposal, type AiProvider } from "../ai-api";
import { AiMessage } from "../AiMessage";
import { AiToolCall } from "../AiToolCall";
@@ -1,9 +1,9 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "../errors";
import { useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import { Check, Loader2, TriangleAlert, X } from "lucide-react";
import { Button } from "@/components/button";
import { Button } from "@termix/plugin-sdk/ui";
import {
markAiProposalRunInTerminal,
rejectAiProposal,
@@ -7,7 +7,7 @@ interface TerminalHost {
id: number | string;
name?: string;
ip?: string;
enableAiAssistant?: boolean;
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
[key: string]: unknown;
}
@@ -1,68 +0,0 @@
import { useEffect, useState } from "react";
export interface AiAvailability {
/** Admin kill switch. When false the feature does not exist for anyone. */
globallyEnabled: boolean;
/** Global switch and the user's own opt-in, which is what the rail needs. */
userEnabled: boolean;
/** False until the status call answers, so nothing flashes in or out. */
loaded: boolean;
}
/** Fired when the AI status may have changed, so every surface re-reads it. */
export const AI_STATUS_CHANGED_EVENT = "aiStatusChanged";
export function notifyAiStatusChanged(): void {
window.dispatchEvent(new Event(AI_STATUS_CHANGED_EVENT));
}
/**
* One source of truth for whether any AI surface should render.
*
* The admin global is a hard kill switch: when it is off the assistant is
* hidden everywhere for everyone, including the tab bar, command palette,
* mobile bar, and the navigation visibility toggles, so nobody can reach or
* even see an entry the server would refuse anyway.
*/
export function useAiAvailability(): AiAvailability {
const [state, setState] = useState<AiAvailability>({
globallyEnabled: false,
userEnabled: false,
loaded: false,
});
useEffect(() => {
let cancelled = false;
const refresh = () => {
import("./ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setState({
globallyEnabled: status.globallyEnabled,
userEnabled: status.globallyEnabled && status.enabled,
loaded: true,
});
})
.catch(() => {
if (!cancelled)
setState({
globallyEnabled: false,
userEnabled: false,
loaded: true,
});
});
};
refresh();
window.addEventListener(AI_STATUS_CHANGED_EVENT, refresh);
// The profile toggle and onboarding still fire this older event.
window.addEventListener("hiddenRailTabsChanged", refresh);
return () => {
cancelled = true;
window.removeEventListener(AI_STATUS_CHANGED_EVENT, refresh);
window.removeEventListener("hiddenRailTabsChanged", refresh);
};
}, []);
return state;
}
+3 -14
View File
@@ -1,18 +1,8 @@
import { getErrorMessage } from "@/lib/error-message";
import { getErrorMessage } from "./errors";
import { useCallback, useRef, useState } from "react";
import { authApi } from "@/main-axios";
import { aiApp } from "./app-ref";
import type { AiProposal } from "./ai-api";
/**
* Reuses whatever base authApi resolved to, so the stream follows the same
* dev proxy, Electron localhost and reverse-proxy base path rules as every
* other call instead of hardcoding an origin.
*/
function streamUrl(): string {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
return `${base}/ai/chat/stream`;
}
/**
* Drives the chat stream.
*
@@ -86,10 +76,9 @@ export function useAiStream() {
let toolSequence = 0;
try {
const response = await fetch(streamUrl(), {
const response = await aiApp().fetch("chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
signal: controller.signal,
body: JSON.stringify({
message: input.message,
+22 -28
View File
@@ -1,7 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { getSnippets } from "@/api/snippets-api";
import { invokeAction } from "@termix/plugin-sdk/frontend";
import { invokeAction, useHosts } from "@termix/plugin-sdk/frontend";
/**
* Backs the @-mention picker in the composer.
@@ -39,11 +37,14 @@ export function activeMentionQuery(
return { query, start: at };
}
/** The automations plugin's list, or nothing when it is not running. */
async function listAutomationsForMentions(): Promise<
{ id: number; name: string }[]
> {
const result = await invokeAction("automations.list");
/**
* Another plugin's list through its action, or nothing when it is not
* running, so the assistant works without it and never imports its code.
*/
async function listFromAction(
actionId: string,
): Promise<{ id: number; name: string }[]> {
const result = await invokeAction(actionId);
return Array.isArray(result)
? (result as { id: number; name: string }[])
: [];
@@ -51,35 +52,28 @@ async function listAutomationsForMentions(): Promise<
export function useMentions(enabled: boolean) {
const [items, setItems] = useState<MentionItem[]>([]);
const { hosts } = useHosts();
useEffect(() => {
if (!enabled) return;
let cancelled = false;
(async () => {
const [hosts, snippets, automations] = await Promise.allSettled([
getSSHHosts(),
getSnippets(),
// Through the automations plugin's action, so the assistant works
// without it and never imports its code.
listAutomationsForMentions(),
const [snippets, automations] = await Promise.allSettled([
listFromAction("snippets.list"),
listFromAction("automations.list"),
]);
if (cancelled) return;
const collected: MentionItem[] = [];
const collected: MentionItem[] = hosts.map((host) => ({
kind: "host" as const,
id: Number(host.id),
label: host.name || host.ip,
detail: host.ip,
}));
if (hosts.status === "fulfilled") {
for (const host of (hosts.value ?? []) as any[]) {
collected.push({
kind: "host",
id: host.id,
label: host.name || host.ip,
detail: host.ip,
});
}
}
if (snippets.status === "fulfilled") {
for (const snippet of (snippets.value ?? []) as any[]) {
for (const snippet of snippets.value) {
collected.push({
kind: "snippet",
id: snippet.id,
@@ -88,7 +82,7 @@ export function useMentions(enabled: boolean) {
}
}
if (automations.status === "fulfilled") {
for (const automation of (automations.value ?? []) as any[]) {
for (const automation of automations.value) {
collected.push({
kind: "automation",
id: automation.id,
@@ -103,7 +97,7 @@ export function useMentions(enabled: boolean) {
return () => {
cancelled = true;
};
}, [enabled]);
}, [enabled, hosts]);
const search = useCallback(
(query: string): MentionItem[] => {