mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
281 lines
9.6 KiB
TypeScript
281 lines
9.6 KiB
TypeScript
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<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>,
|
|
deps: ToolDeps,
|
|
): 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": {
|
|
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<string, unknown>;
|
|
|
|
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<string, unknown>;
|
|
|
|
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> = {};
|
|
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<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");
|
|
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>();
|