feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
This commit is contained in:
LukeGus committed 2026-09-21 15:20:05 -05:00
1 parent c58ccbfc3f
commit 504710b065
43 files changed
+568 -262

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
import { proposeTools } from "./propose-tools.js";
import { readTools } from "./read-tools.js";
import type { AiTool, ToolDefinitionShape } from "./types.js";
/**
* The allowlist, and the security boundary for the whole feature.
*
* A model can only ever invoke what appears here. Route-level RBAC gates the
* HTTP API, but tools run in-process with the calling user's identity and never
* pass through a router, so "the assistant cannot reach credentials or user
* administration" is a property of this list, not of the permission system.
*
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
* SSO or instance settings is deliberately absent and must stay absent.
*/
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
export function getTool(name: string): AiTool | undefined {
return BY_NAME.get(name);
}
export function listToolNames(): string[] {
return AI_TOOLS.map((tool) => tool.name);
}
/**
* Domains the assistant must never be able to reach, in any tool, ever.
* The catalog test asserts no tool name references these.
*/
export const FORBIDDEN_DOMAINS = [
"credential",
"vault",
"rbac",
"role",
"permission",
"user_admin",
"password",
"totp",
"webauthn",
"passkey",
"api_key",
"session",
"oidc",
"sso",
"ldap",
"termix_id",
"identity",
"certificate",
"opkssh",
"stepca",
"step_ca",
"acme",
"ssl",
"audit",
"sync",
"settings",
];
/** Tool definitions in the shape the provider adapters expect. */
export function toolDefinitions(): ToolDefinitionShape[] {
return AI_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
}));
}
@@ -0,0 +1,136 @@
/**
* Which commands the assistant may run without a per-command approval click,
* for users who opted into read-only execution.
*
* The check parses the command into arguments and matches the resolved binary
* against the allowlist. Substring matching would be trivially defeated
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
* redirect or substitute a second command rejects the whole string outright.
*/
export const READ_ONLY_COMMANDS = new Set([
"df",
"du",
"free",
"uptime",
"uname",
"whoami",
"hostname",
"id",
"ps",
"top",
"systemctl",
"journalctl",
"docker",
"ip",
"ss",
"netstat",
"lsblk",
"cat",
"ls",
"stat",
"which",
"date",
"lscpu",
"vmstat",
"iostat",
]);
/** Characters that let one command become several. */
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
/** Subcommands that are safe for otherwise-powerful binaries. */
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
systemctl: new Set([
"status",
"list-units",
"list-unit-files",
"is-active",
"is-enabled",
"show",
]),
docker: new Set([
"ps",
"stats",
"images",
"logs",
"inspect",
"version",
"info",
]),
ip: new Set(["a", "addr", "link", "route", "neigh"]),
};
/** Paths `cat` may read. Anything else could disclose credentials. */
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
export interface CommandCheck {
allowed: boolean;
reason?: string;
}
export function isReadOnlyCommand(raw: string): CommandCheck {
const command = raw.trim();
if (!command) return { allowed: false, reason: "Empty command" };
if (SHELL_METACHARACTERS.test(command)) {
return {
allowed: false,
reason: "Command chaining, redirection and substitution are not allowed",
};
}
const parts = command.split(/\s+/).filter(Boolean);
if (!parts.length) return { allowed: false, reason: "Empty command" };
// Reject env-prefixed and privilege-escalating forms outright.
const head = parts[0];
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
return { allowed: false, reason: `${head} is not allowed` };
}
// A path like /usr/bin/df resolves to its basename.
const binary = head.includes("/")
? head.slice(head.lastIndexOf("/") + 1)
: head;
if (!READ_ONLY_COMMANDS.has(binary)) {
return {
allowed: false,
reason: `${binary} is not on the read-only allowlist`,
};
}
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
if (allowedSubcommands) {
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
if (!subcommand || !allowedSubcommands.has(subcommand)) {
return {
allowed: false,
reason:
`${binary} ${subcommand ?? ""}`.trim() +
" is not on the read-only allowlist",
};
}
}
if (binary === "cat") {
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
if (!targets.length) {
return { allowed: false, reason: "cat needs a file path" };
}
for (const target of targets) {
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
);
if (!permitted) {
return {
allowed: false,
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
};
}
}
}
return { allowed: true };
}
+332
View File
@@ -0,0 +1,332 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentFleetRepository,
createCurrentHostRepository,
createCurrentSnippetRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { validateDefinition } from "../../../../src/backend/database/routes/automations.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
import {
createFleetSshFactory,
getFleetPoolKey,
} from "../../../../src/backend/hosts/ssh-client-factory.js";
import { withConnection } from "../../../../src/backend/hosts/ssh-connection-pool.js";
import { getTool } from "./catalog.js";
/** Approved commands get a bounded window rather than hanging the request. */
const COMMAND_TIMEOUT_MS = 60_000;
/**
* Applies an approved proposal.
*
* The stored payload is treated as untrusted input even though the server wrote
* it: the proposal could have sat in the table across a release, and defending
* the apply path rather than the create path means one place to get right.
* Everything goes through the same repositories a human action uses, scoped to
* the approving user.
*/
export interface ApplyResult {
ok: boolean;
summary: string;
}
function requireNumber(value: unknown, field: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${field} must be a positive integer`);
}
return parsed;
}
function requireString(value: unknown, field: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${field} is required`);
}
return value.trim();
}
function optionalString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
export async function applyProposal(
kind: string,
payload: Record<string, unknown>,
userId: string,
): Promise<ApplyResult> {
// A payload whose tool no longer exists is refused rather than guessed at.
if (!getTool(kind)) {
throw new Error(`Unknown proposal kind: ${kind}`);
}
switch (kind) {
case "propose_create_host": {
const created = await createCurrentHostRepository().create({
userId,
name: requireString(payload.name, "name"),
ip: requireString(payload.ip, "ip"),
port: Number(payload.port) || 22,
username: optionalString(payload.username) ?? "",
folder: optionalString(payload.folder) ?? "",
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
} as any);
return {
ok: true,
summary: `Created host ${(created as any).name ?? ""}`.trim(),
};
}
case "propose_update_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!existing) throw new Error("Host not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.ip !== undefined)
updates.ip = requireString(changes.ip, "ip");
if (changes.port !== undefined) updates.port = Number(changes.port);
if (changes.username !== undefined)
updates.username = optionalString(changes.username) ?? "";
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder) ?? "";
if (changes.tags !== undefined) {
updates.tags = JSON.stringify(
Array.isArray(changes.tags) ? changes.tags : [],
);
}
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentHostRepository().updateForUser(
userId,
hostId,
updates as any,
);
return { ok: true, summary: `Updated host ${hostId}` };
}
case "propose_delete_host": {
const hostId = requireNumber(payload.hostId, "hostId");
const deleted = await createCurrentHostRepository().deleteForUser(
userId,
hostId,
);
if (!deleted) throw new Error("Host not found");
return { ok: true, summary: `Deleted host ${hostId}` };
}
case "propose_create_snippet": {
const created = await createCurrentSnippetRepository().createSnippet(
userId,
{
name: requireString(payload.name, "name"),
content: requireString(payload.content, "content"),
description: optionalString(payload.description),
folder: optionalString(payload.folder),
} as any,
);
return {
ok: true,
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_update_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const changes = (payload.changes ?? {}) as Record<string, unknown>;
const existing = await createCurrentSnippetRepository().findOwnedById(
userId,
snippetId,
);
if (!existing) throw new Error("Snippet not found");
const updates: Record<string, unknown> = {};
if (changes.name !== undefined)
updates.name = requireString(changes.name, "name");
if (changes.content !== undefined)
updates.content = requireString(changes.content, "content");
if (changes.description !== undefined)
updates.description = optionalString(changes.description);
if (changes.folder !== undefined)
updates.folder = optionalString(changes.folder);
if (!Object.keys(updates).length) {
return { ok: false, summary: "Nothing to change" };
}
await createCurrentSnippetRepository().updateSnippet(
userId,
snippetId,
updates as any,
);
return { ok: true, summary: `Updated snippet ${snippetId}` };
}
case "propose_delete_snippet": {
const snippetId = requireNumber(payload.snippetId, "snippetId");
const deleted = await createCurrentSnippetRepository().deleteSnippet(
userId,
snippetId,
);
if (!deleted) throw new Error("Snippet not found");
return { ok: true, summary: `Deleted snippet ${snippetId}` };
}
case "propose_create_fleet": {
const fleet = await createCurrentFleetRepository().create(userId, {
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
} as any);
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
let added = 0;
for (const raw of hostIds) {
const hostId = Number(raw);
if (!Number.isInteger(hostId) || hostId <= 0) continue;
// Only hosts the approving user owns can join their fleet.
const host = await createCurrentHostRepository().findByIdForUser(
userId,
hostId,
);
if (!host) continue;
await createCurrentFleetRepository().addMember(
(fleet as any).id,
hostId,
);
added += 1;
}
return {
ok: true,
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
};
}
case "propose_create_alert_rule": {
const created = await createCurrentAlertRepository().createAlertRule({
userId,
name: requireString(payload.name, "name"),
hostId:
payload.hostId === null || payload.hostId === undefined
? null
: requireNumber(payload.hostId, "hostId"),
enabled: true,
triggerType: requireString(payload.triggerType, "triggerType"),
thresholdValue:
payload.thresholdValue === null ||
payload.thresholdValue === undefined
? null
: Number(payload.thresholdValue),
thresholdDurationSeconds:
payload.thresholdDurationSeconds === null ||
payload.thresholdDurationSeconds === undefined
? null
: Number(payload.thresholdDurationSeconds),
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
channelIds: [],
} as any);
return {
ok: true,
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
};
}
case "propose_create_automation": {
// Reuses the same validator the automations route runs, so an
// LLM-authored definition is held to exactly the human standard.
const validation = validateDefinition(payload.definition);
if (!validation.ok || !validation.definition) {
throw new Error(
validation.error ?? "The automation definition is invalid",
);
}
const created = await createCurrentAutomationRepository().create({
userId,
name: requireString(payload.name, "name"),
description: optionalString(payload.description),
definition: JSON.stringify(validation.definition),
// Starts disabled: an automation the user has not watched run once
// should not begin firing against their servers on approval.
enabled: false,
});
return {
ok: true,
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
};
}
case "propose_run_command": {
const hostId = requireNumber(payload.hostId, "hostId");
const command = requireString(payload.command, "command");
// resolveHostById, not the raw repository row: it runs the connect-level
// permission check, decrypts auth under the owner's key, and resolves the
// jump host chain. A plain row has none of that, so the SSH factory saw
// an unresolved jumpHosts field and failed the connection.
const host = await resolveHostById(hostId, userId);
if (!host) throw new Error("Host not found");
const result = await runCommandOnHost(host, command);
if (result.error) {
throw new Error(result.error);
}
return {
ok: true,
summary: result.output?.slice(0, 2000) ?? "(no output)",
};
}
default:
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
}
}
/**
* Runs one approved command over the shared SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
host: Record<string, any>,
command: string,
): Promise<{ output?: string; error?: string }> {
try {
const result = await withConnection(
getFleetPoolKey(host as any),
createFleetSshFactory(host as any),
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
);
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
if (result.code === 0 || result.code === null) {
return { output: output || "(no output)" };
}
return { error: `Exited with code ${result.code}: ${output}`.trim() };
} catch (error) {
return {
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Kinds the route handles itself rather than through applyProposal.
* Empty: everything the assistant can propose can now be applied.
*/
export const ROUTE_APPLIED_KINDS = new Set<string>();
+279
View File
@@ -0,0 +1,279 @@
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
/**
* Propose tools never mutate anything. They return a draft that is stored as a
* pending proposal and rendered as a card; the change happens only when the
* user approves it, and the payload is re-validated at that point.
*/
export const proposeTools: AiTool[] = [
{
name: "propose_create_host",
description:
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
category: "propose",
parameters: objectSchema(
{
name: str("Display name for the host"),
ip: str("Hostname or IP address"),
port: num("SSH port (defaults to 22)"),
username: str("SSH username"),
folder: str("Folder to file the host under"),
tags: {
type: "array",
items: { type: "string" },
description: "Tags to apply",
},
},
["name", "ip"],
),
handler: async (args) =>
proposal("propose_create_host", `Add host ${String(args.name)}`, {
name: args.name,
ip: args.ip,
port: args.port ?? 22,
username: args.username ?? null,
folder: args.folder ?? null,
tags: args.tags ?? [],
}),
},
{
name: "propose_update_host",
description:
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to update"),
name: str("New display name"),
ip: str("New hostname or IP address"),
port: num("New SSH port"),
username: str("New SSH username"),
folder: str("New folder"),
tags: {
type: "array",
items: { type: "string" },
description: "Replacement tag list",
},
},
["hostId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of [
"name",
"ip",
"port",
"username",
"folder",
"tags",
]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_host",
`Update host ${String(args.hostId)}`,
{ hostId: args.hostId, changes },
);
},
},
{
name: "propose_delete_host",
description:
"Propose removing a host. Use sparingly and explain why in the reason.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to delete"),
reason: str("Why this host should be removed"),
},
["hostId", "reason"],
),
handler: async (args) =>
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
hostId: args.hostId,
reason: args.reason,
}),
},
{
name: "propose_create_snippet",
description:
"Propose saving a new command snippet the user can run against their hosts.",
category: "propose",
parameters: objectSchema(
{
name: str("Snippet name"),
content: str("The command text"),
description: str("What the snippet does"),
folder: str("Folder to file it under"),
},
["name", "content"],
),
handler: async (args) =>
proposal(
"propose_create_snippet",
`Create snippet ${String(args.name)}`,
{
name: args.name,
content: args.content,
description: args.description ?? null,
folder: args.folder ?? null,
},
),
},
{
name: "propose_update_snippet",
description: "Propose editing an existing snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to update"),
name: str("New name"),
content: str("New command text"),
description: str("New description"),
folder: str("New folder"),
},
["snippetId"],
),
handler: async (args) => {
const changes: Record<string, unknown> = {};
for (const field of ["name", "content", "description", "folder"]) {
if (args[field] !== undefined) changes[field] = args[field];
}
return proposal(
"propose_update_snippet",
`Update snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, changes },
);
},
},
{
name: "propose_delete_snippet",
description: "Propose deleting a snippet.",
category: "propose",
parameters: objectSchema(
{
snippetId: num("The snippet id to delete"),
reason: str("Why this snippet should be removed"),
},
["snippetId", "reason"],
),
handler: async (args) =>
proposal(
"propose_delete_snippet",
`Delete snippet ${String(args.snippetId)}`,
{ snippetId: args.snippetId, reason: args.reason },
),
},
{
name: "propose_create_automation",
description:
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
category: "propose",
parameters: objectSchema(
{
name: str("Automation name"),
description: str("What the automation does"),
definition: {
type: "object",
description:
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
},
},
["name", "definition"],
),
handler: async (args) =>
proposal(
"propose_create_automation",
`Create automation ${String(args.name)}`,
{
name: args.name,
description: args.description ?? null,
definition: args.definition,
},
),
},
{
name: "propose_create_fleet",
description: "Propose grouping hosts into a new fleet.",
category: "propose",
parameters: objectSchema(
{
name: str("Fleet name"),
description: str("What this fleet is for"),
hostIds: {
type: "array",
items: { type: "number" },
description: "Host ids to add as members",
},
},
["name"],
),
handler: async (args) =>
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
name: args.name,
description: args.description ?? null,
hostIds: args.hostIds ?? [],
}),
},
{
name: "propose_create_alert_rule",
description:
"Propose a new alert rule that fires when a host metric crosses a threshold.",
category: "propose",
parameters: objectSchema(
{
name: str("Rule name"),
hostId: num("Host id to watch, or omit to watch all hosts"),
triggerType: str(
"What to watch, for example cpu, memory, disk or host_status",
),
thresholdValue: num("Threshold to compare against"),
thresholdDurationSeconds: num(
"How long the breach must persist before firing",
),
cooldownMinutes: num("Minimum minutes between repeat firings"),
},
["name", "triggerType"],
),
handler: async (args) =>
proposal(
"propose_create_alert_rule",
`Create alert rule ${String(args.name)}`,
{
name: args.name,
hostId: args.hostId ?? null,
triggerType: args.triggerType,
thresholdValue: args.thresholdValue ?? null,
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
cooldownMinutes: args.cooldownMinutes ?? 15,
},
),
},
{
name: "propose_run_command",
description:
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
category: "propose",
parameters: objectSchema(
{
hostId: num("The host id to run on"),
command: str("The exact command to run"),
explanation: str("What the command does and why it is needed"),
},
["hostId", "command", "explanation"],
),
handler: async (args) =>
proposal(
"propose_run_command",
// Deliberately short: the card renders the command in its own block,
// so repeating it here made every card twice as tall as it needed.
`Run a command on host ${String(args.hostId)}`,
{
hostId: args.hostId,
command: args.command,
explanation: args.explanation,
},
),
},
];
+331
View File
@@ -0,0 +1,331 @@
import {
createCurrentAlertRepository,
createCurrentAutomationRepository,
createCurrentCommandHistoryRepository,
createCurrentFleetRepository,
createCurrentHomepageItemRepository,
createCurrentHostRepository,
createCurrentNetworkTopologyRepository,
createCurrentSnippetRepository,
createCurrentWorkspaceRepository,
} from "../../../../src/backend/database/repositories/factory.js";
import { num, objectSchema, type AiTool } from "./types.js";
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
* afterwards as a second line of defense, but the projection here is the
* primary control: a field that is never selected cannot leak.
*/
interface HostSummary {
id: number;
name: string | null;
ip: string | null;
port: number | null;
username: string | null;
folder: string | null;
tags: unknown;
protocol: string | null;
enableTerminal: boolean | null;
enableFileManager: boolean | null;
enableTunnel: boolean | null;
enableDocker: boolean | null;
}
function toHostSummary(host: Record<string, any>): HostSummary {
return {
id: host.id,
name: host.name ?? null,
ip: host.ip ?? null,
port: host.port ?? null,
username: host.username ?? null,
folder: host.folder ?? null,
tags: host.tags ?? null,
protocol: host.protocol ?? null,
enableTerminal: host.enableTerminal ?? null,
enableFileManager: host.enableFileManager ?? null,
enableTunnel: host.enableTunnel ?? null,
enableDocker: host.enableDocker ?? null,
};
}
export const readTools: AiTool[] = [
{
name: "list_hosts",
description:
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const hosts = await createCurrentHostRepository().listByUserId(
context.userId,
);
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
},
},
{
name: "get_host",
description:
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
category: "read",
parameters: objectSchema(
{ hostId: num("The host id, as returned by list_hosts") },
["hostId"],
),
handler: async (args, context) => {
const hostId = Number(args.hostId);
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
return { host: toHostSummary(host as any) };
},
},
{
name: "list_fleets",
description:
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const fleets = await createCurrentFleetRepository().listByUser(
context.userId,
);
return {
fleets: fleets.map((fleet: any) => ({
id: fleet.id,
name: fleet.name,
description: fleet.description ?? null,
})),
};
},
},
{
name: "list_snippets",
description:
"List the user's saved command snippets, including their folder and the command text.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
context.userId,
);
return {
snippets: snippets.map((snippet: any) => ({
id: snippet.id,
name: snippet.name,
content: snippet.content,
folder: snippet.folder ?? null,
description: snippet.description ?? null,
})),
};
},
},
{
name: "list_automations",
description:
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const automations = await createCurrentAutomationRepository().list(
context.userId,
);
return {
automations: automations.map((automation: any) => ({
id: automation.id,
name: automation.name,
description: automation.description ?? null,
enabled: automation.enabled,
lastRunAt: automation.lastRunAt ?? null,
lastRunStatus: automation.lastRunStatus ?? null,
})),
};
},
},
{
name: "get_automation",
description:
"Get one automation's full definition (trigger and steps) by id.",
category: "read",
parameters: objectSchema(
{
automationId: num("The automation id, as returned by list_automations"),
},
["automationId"],
),
handler: async (args, context) => {
const automation = await createCurrentAutomationRepository().findForUser(
Number(args.automationId),
context.userId,
);
if (!automation) return { error: "Automation not found" };
return {
automation: {
id: (automation as any).id,
name: (automation as any).name,
enabled: (automation as any).enabled,
definition: (automation as any).definition,
},
};
},
},
{
name: "list_workspaces",
description:
"List the user's saved workspace layouts (named sets of open tabs and splits).",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const workspaces = await createCurrentWorkspaceRepository().listByUser(
context.userId,
);
return {
workspaces: workspaces.map((workspace: any) => ({
id: workspace.id,
name: workspace.name,
isDefault: workspace.isDefault ?? false,
})),
};
},
},
{
name: "list_alert_rules",
description:
"List the user's alert rules with their thresholds and enabled state.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const rules = await createCurrentAlertRepository().listAlertRules(
context.userId,
);
return {
rules: rules.map((rule) => ({
id: rule.id,
name: rule.name,
hostId: rule.host_id,
enabled: rule.enabled === 1,
triggerType: rule.trigger_type,
thresholdValue: rule.threshold_value,
thresholdDurationSeconds: rule.threshold_duration_seconds,
cooldownMinutes: rule.cooldown_minutes,
})),
};
},
},
{
name: "list_notification_channels",
description:
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const channels =
await createCurrentAlertRepository().listNotificationChannels(
context.userId,
);
return {
channels: channels.map((channel) => ({
id: channel.id,
name: channel.name,
type: channel.type,
enabled: channel.enabled === 1,
})),
};
},
},
{
name: "get_alert_firings",
description:
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
category: "read",
parameters: objectSchema({
limit: num("How many firings to return (default 25, max 100)"),
}),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const result = await createCurrentAlertRepository().listAlertFirings({
userId: context.userId,
limit,
offset: 0,
});
return {
firings: (result.firings ?? []).map((firing) => ({
id: firing.id,
ruleName: firing.rule_name,
hostName: firing.host_name,
firedAt: firing.fired_at,
resolvedAt: firing.resolved_at,
severity: firing.severity,
message: firing.message,
acknowledged: firing.acknowledged === 1,
})),
};
},
},
{
name: "list_homepage_items",
description: "List the user's homepage service-link tiles.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const items = await createCurrentHomepageItemRepository().listByUserId(
context.userId,
);
return {
items: (items as any[]).map((item) => ({
id: item.id,
typeId: item.typeId,
title: item.title ?? null,
})),
};
},
},
{
name: "get_command_history",
description:
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
category: "read",
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
limit: num("How many entries to return (default 25, max 100)"),
},
["hostId"],
),
handler: async (args, context) => {
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
const hostId = Number(args.hostId);
// Ownership is enforced here rather than trusted from the model.
const host = await createCurrentHostRepository().findByIdForUser(
context.userId,
hostId,
);
if (!host) return { error: "Host not found" };
const commands =
await createCurrentCommandHistoryRepository().listCommandsForHost(
context.userId,
hostId,
limit,
);
return { commands };
},
},
{
name: "get_network_topology",
description: "The user's saved network topology graph, if they have one.",
category: "read",
parameters: objectSchema({}),
handler: async (_args, context) => {
const topology =
await createCurrentNetworkTopologyRepository().findByUserId(
context.userId,
);
if (!topology) return { topology: null };
return { topology: (topology as any).data ?? null };
},
},
];
+72
View File
@@ -0,0 +1,72 @@
export type ToolCategory = "read" | "propose";
export interface ToolContext {
/** Always taken from the verified JWT, never from model input. */
userId: string;
conversationId: number;
/** Per-user opt-in for running allowlisted read-only commands. */
allowReadOnlyCommands: boolean;
}
export interface AiTool {
name: string;
description: string;
category: ToolCategory;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
* Read tools return data to feed back to the model. Propose tools return a
* ProposalDraft and must not mutate anything.
*/
handler: (
args: Record<string, unknown>,
context: ToolContext,
) => Promise<unknown>;
}
/** What a provider adapter needs to describe a tool to its model. */
export interface ToolDefinitionShape {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface ProposalDraft {
__proposal: true;
kind: string;
summary: string;
payload: Record<string, unknown>;
}
export function isProposalDraft(value: unknown): value is ProposalDraft {
return (
typeof value === "object" &&
value !== null &&
(value as ProposalDraft).__proposal === true
);
}
export function proposal(
kind: string,
summary: string,
payload: Record<string, unknown>,
): ProposalDraft {
return { __proposal: true, kind, summary, payload };
}
/** Small helper so tool schemas stay readable. */
export function objectSchema(
properties: Record<string, unknown>,
required: string[] = [],
): Record<string, unknown> {
return {
type: "object",
properties,
required,
additionalProperties: false,
};
}
export const str = (description: string) => ({ type: "string", description });
export const num = (description: string) => ({ type: "number", description });
export const bool = (description: string) => ({ type: "boolean", description });