fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"typecheck": "tsc -b ../../tsconfig.plugins.json ../../tsconfig.plugins.frontend.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
+4 -2
View File
@@ -56,6 +56,7 @@ export function registerAiRoutes(
notify: ctx.notify,
rbac: ctx.rbac,
ssh: ctx.ssh,
audit: ctx.audit,
};
const logError = (message: string, error: unknown) =>
@@ -728,8 +729,9 @@ export function registerAiRoutes(
).aiAccess ?? { allowReadOnlyCommands: false };
const hosts = await ctx.hosts.list();
const config = await providerConfig(provider);
const tools = availableTools((service) =>
serviceAvailable(ctx.services, service),
const tools = availableTools(
(service) => serviceAvailable(ctx.services, service),
{ allowReadOnlyCommands: access.allowReadOnlyCommands },
);
// no-transform keeps core's compression from buffering the stream,
+7 -1
View File
@@ -64,8 +64,14 @@ export const FORBIDDEN_DOMAINS = [
*/
export function availableTools(
isAvailable: (service: string) => boolean,
options: { allowReadOnlyCommands?: boolean } = {},
): AiTool[] {
return AI_TOOLS.filter((tool) => !tool.service || isAvailable(tool.service));
return AI_TOOLS.filter(
(tool) =>
(!tool.service || isAvailable(tool.service)) &&
(!tool.requiresReadOnlyCommands ||
options.allowReadOnlyCommands === true),
);
}
/** Tool definitions in the shape the provider adapters expect. */
+1 -1
View File
@@ -247,7 +247,7 @@ export async function applyProposal(
* Runs one approved command over this plugin's SSH pool, mirroring how an
* automation run_command step executes.
*/
async function runCommandOnHost(
export async function runCommandOnHost(
deps: ToolDeps,
hostId: number,
command: string,
+47 -1
View File
@@ -1,6 +1,11 @@
import type { PluginHostSummary } from "@termix/plugin-sdk/backend";
import { num, objectSchema, type AiTool } from "./types.js";
import { num, objectSchema, str, type AiTool } from "./types.js";
import { readService, SERVICE } from "../services.js";
import { isReadOnlyCommand } from "./command-allowlist.js";
import { runCommandOnHost } from "./executor.js";
/** Output handed back to the model is capped; a log can be huge. */
const MAX_COMMAND_OUTPUT = 8000;
/**
* Read tools project explicit fields rather than spreading rows. Redaction runs
@@ -30,6 +35,47 @@ function toHostSummary(host: PluginHostSummary) {
}
export const readTools: AiTool[] = [
{
name: "run_readonly_command",
description:
"Run a read-only diagnostic command on a host and get its output, for example df -h, uptime, free -m or systemctl status nginx. Only commands on a fixed read-only allowlist run; anything else must be proposed with propose_run_command.",
category: "read",
requiresReadOnlyCommands: true,
parameters: objectSchema(
{
hostId: num("The host id, as returned by list_hosts"),
command: str("The command to run"),
},
["hostId", "command"],
),
handler: async (args, context) => {
if (!context.allowReadOnlyCommands) {
return { error: "The user has not allowed read-only commands" };
}
const hostId = Number(args.hostId);
const command = typeof args.command === "string" ? args.command : "";
const check = isReadOnlyCommand(command);
if (!check.allowed) {
return { error: check.reason ?? "Command is not read-only" };
}
const access = await context.deps.hosts.checkAccess(hostId, "connect");
if (!access.hasAccess) return { error: "Host not found" };
const result = await runCommandOnHost(context.deps, hostId, command);
await context.deps.audit
.record({
action: "ai_readonly_command",
resourceType: "host",
resourceId: String(hostId),
details: JSON.stringify({ command }),
success: !result.error,
errorMessage: result.error,
})
.catch(() => undefined);
if (result.error) return { error: result.error };
return { output: (result.output ?? "").slice(0, MAX_COMMAND_OUTPUT) };
},
},
{
name: "list_hosts",
description:
+3 -1
View File
@@ -5,7 +5,7 @@ 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"
"hosts" | "services" | "notify" | "rbac" | "ssh" | "audit"
>;
export interface ToolContext {
@@ -26,6 +26,8 @@ export interface AiTool {
* not offered to the model at all.
*/
service?: string;
/** Offered only to a user who turned on read-only commands. */
requiresReadOnlyCommands?: boolean;
/** JSON Schema for the arguments, sent to the provider verbatim. */
parameters: Record<string, unknown>;
/**
+2 -9
View File
@@ -184,18 +184,11 @@ export function activate(app: TermixApp): void {
applyStatus();
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();
};
// the settings screen.
app.onSettingsChanged(() => 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(() => {
for (const event of events) window.removeEventListener(event, refresh);
window.removeEventListener("focus", onFocus);
});
}
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from "vitest";
import { availableTools, getTool } from "../../../src/backend/tools/catalog.js";
import type { ToolContext } from "../../../src/backend/tools/types.js";
const tool = getTool("run_readonly_command")!;
function contextWith(overrides: Partial<ToolContext> = {}) {
const withConnection = vi.fn(async () => ({
stdout: "Filesystem Size",
stderr: "",
code: 0,
}));
const record = vi.fn(async () => {});
const context = {
userId: "user-1",
conversationId: 1,
allowReadOnlyCommands: true,
deps: {
hosts: {
checkAccess: vi.fn(async () => ({ hasAccess: true })),
},
ssh: { withConnection },
audit: { record },
},
...overrides,
} as unknown as ToolContext;
return { context, withConnection, record };
}
describe("run_readonly_command", () => {
it("is only offered to a user who opted in", () => {
const names = (allow: boolean) =>
availableTools(() => true, { allowReadOnlyCommands: allow }).map(
(entry) => entry.name,
);
expect(names(false)).not.toContain("run_readonly_command");
expect(names(true)).toContain("run_readonly_command");
});
it("runs an allowlisted command and audits it", async () => {
const { context, withConnection, record } = contextWith();
const result = await tool.handler({ hostId: 4, command: "df -h" }, context);
expect(result).toEqual({ output: "Filesystem Size" });
expect(withConnection).toHaveBeenCalledTimes(1);
expect(record).toHaveBeenCalledWith(
expect.objectContaining({ action: "ai_readonly_command", success: true }),
);
});
it("refuses a command that is not read-only", async () => {
const { context, withConnection } = contextWith();
const result = (await tool.handler(
{ hostId: 4, command: "rm -rf /tmp/x" },
context,
)) as { error?: string };
expect(result.error).toBeTruthy();
expect(withConnection).not.toHaveBeenCalled();
});
it("refuses when the user has not opted in, or cannot reach the host", async () => {
const off = contextWith({ allowReadOnlyCommands: false });
expect(
await tool.handler({ hostId: 4, command: "uptime" }, off.context),
).toEqual({ error: "The user has not allowed read-only commands" });
const denied = contextWith();
(
denied.context.deps.hosts.checkAccess as ReturnType<typeof vi.fn>
).mockResolvedValue({ hasAccess: false });
expect(
await tool.handler({ hostId: 4, command: "uptime" }, denied.context),
).toEqual({ error: "Host not found" });
expect(denied.withConnection).not.toHaveBeenCalled();
});
});