import type { Client } from "ssh2"; import type { PluginHostUpdateInput } from "@termix/plugin-sdk/backend"; import { execCommand } from "@termix/plugin-sdk/host-commands"; import { getTool } from "./catalog.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; /** * 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 core APIs and plugin services a human * action uses, and runs as the approving user: the apply request's actor. */ 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; } /** 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 { 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, deps: ToolDeps, ): Promise { // 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": { 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) ?? "", // 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; const updates: PluginHostUpdateInput = {}; 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 = joinTags(changes.tags); if (!Object.keys(updates).length) { return { ok: false, summary: "Nothing to change" }; } // 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"); // 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 requireService( deps.services, SERVICE.snippets, ).create({ name: requireString(payload.name, "name"), content: requireString(payload.content, "content"), description: optionalString(payload.description), folder: optionalString(payload.folder), }); return { ok: true, summary: `Created snippet ${created.name}`.trim(), }; } case "propose_update_snippet": { const snippetId = requireNumber(payload.snippetId, "snippetId"); const changes = (payload.changes ?? {}) as Record; const snippets = requireService(deps.services, SERVICE.snippets); const existing = await snippets.get(snippetId); if (!existing) throw new Error("Snippet not found"); const updates: Record = {}; 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 snippets.update(snippetId, updates); return { ok: true, summary: `Updated snippet ${snippetId}` }; } case "propose_delete_snippet": { const snippetId = requireNumber(payload.snippetId, "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 fleets = requireService(deps.services, SERVICE.fleets); const fleet = await fleets.create({ name: requireString(payload.name, "name"), description: optionalString(payload.description), }); 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 access = await deps.hosts.checkAccess(hostId, "view"); if (!access.isOwner) continue; await fleets.addMember(fleet.id, hostId); added += 1; } return { ok: true, summary: `Created fleet ${fleet.name} with ${added} host${added === 1 ? "" : "s"}`, }; } 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 requireService( deps.services, SERVICE.automations, ).create({ name: requireString(payload.name, "name"), description: optionalString(payload.description), definition: payload.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.name} (disabled until you enable it)`, }; } case "propose_run_command": { const hostId = requireNumber(payload.hostId, "hostId"); const command = requireString(payload.command, "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); } 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 this plugin's SSH pool, mirroring how an * automation run_command step executes. */ export async function runCommandOnHost( deps: ToolDeps, hostId: number, command: string, ): Promise<{ output?: string; error?: string }> { try { const result = await deps.ssh.withConnection< Awaited>, Client >(hostId, { pool: "ai", purpose: "fleet" }, (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();