feat: add asynchronous Unraid jobs

This commit is contained in:
Mikei386 committed 2026-08-24 14:52:19 +02:00
1 parent b4b83c2797
commit 88643de738
9 files changed
+165 -11

No files matched your search

+12
View File
@@ -116,6 +116,18 @@ export function parseConfig(content: string): MUAConfig {
) {
cfg.enabledTools.push("unraid_files_inventory");
}
// Async jobs are the bounded long-running equivalent of the already
// unrestricted root shell. Existing administrators who explicitly enabled
// that critical capability receive no new authority class here.
if (!cfg.allToolsEnabled && cfg.enabledTools.includes("unraid_system_shell")) {
for (const name of [
"unraid_system_job_start",
"unraid_system_job_status",
"unraid_system_job_cleanup",
]) {
if (!cfg.enabledTools.includes(name)) cfg.enabledTools.push(name);
}
}
return cfg;
}
+83 -1
View File
@@ -11,10 +11,11 @@
import { spawn } from "bun";
import { createConnection, type Socket } from "net";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
// ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua";
export const MUA_VERSION = "2026.08.24.r021";
export const MUA_VERSION = "2026.08.24.r022";
export const MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-helper.php";
@@ -83,6 +84,87 @@ export async function runShell(cmd: string, timeoutSec = 60): Promise<string> {
}
}
const ASYNC_JOB_ROOT = "/tmp/mua-jobs";
const JOB_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function jobPath(jobId: string): string {
if (!JOB_ID_PATTERN.test(jobId)) throw new Error("invalid job_id");
return `${ASYNC_JOB_ROOT}/${jobId}`;
}
async function tailJobFile(path: string, maxBytes = 12_000): Promise<string> {
if (!existsSync(path)) return "";
const size = statSync(path).size;
const start = Math.max(0, size - maxBytes);
return sanitizeLogOutput(await Bun.file(path).slice(start, size).text(), maxBytes);
}
/** Start arbitrary, explicitly authorized host work without tying up MCP HTTP. */
export async function startShellJob(cmd: string): Promise<string> {
if (cmd.trim() === "") throw new Error("command is required");
mkdirSync(ASYNC_JOB_ROOT, { recursive: true, mode: 0o700 });
const jobId = randomUUID();
const dir = jobPath(jobId);
mkdirSync(dir, { mode: 0o700 });
const script = `${dir}/run.sh`;
// The unrestricted command is already a critical/admin-only capability.
// Keep it out of status output and audit responses because it may contain
// credentials supplied by the operator.
writeFileSync(
script,
`#!/bin/sh\n${cmd}\ncode=$?\nprintf '%s\\n' "$code" > '${dir}/exit_code'\nexit "$code"\n`,
{ mode: 0o700 },
);
const proc = spawn(["/bin/sh", script], {
cwd: "/",
stdin: "ignore",
stdout: Bun.file(`${dir}/stdout.log`),
stderr: Bun.file(`${dir}/stderr.log`),
});
proc.unref();
writeFileSync(
`${dir}/meta.json`,
JSON.stringify({ job_id: jobId, pid: proc.pid, started_at: new Date().toISOString() }),
{ mode: 0o600 },
);
return JSON.stringify({ job_id: jobId, state: "running", pid: proc.pid });
}
/** Poll a job with compact tail output; works across independent MCP calls. */
export async function shellJobStatus(jobId: string): Promise<string> {
const dir = jobPath(jobId);
if (!existsSync(`${dir}/meta.json`)) throw new Error("job not found");
const meta = JSON.parse(readFileSync(`${dir}/meta.json`, "utf-8"));
let running = false;
if (!existsSync(`${dir}/exit_code`)) {
try {
process.kill(Number(meta.pid), 0);
running = true;
} catch {
running = false;
}
}
const exitCode = existsSync(`${dir}/exit_code`)
? Number(readFileSync(`${dir}/exit_code`, "utf-8").trim())
: null;
return JSON.stringify({
job_id: jobId,
state: running ? "running" : exitCode === null ? "unknown" : "finished",
exit_code: exitCode,
started_at: meta.started_at,
stdout_tail: await tailJobFile(`${dir}/stdout.log`),
stderr_tail: await tailJobFile(`${dir}/stderr.log`),
});
}
/** Remove only one completed MUA job directory. */
export async function cleanupShellJob(jobId: string): Promise<string> {
const status = JSON.parse(await shellJobStatus(jobId));
if (status.state === "running") throw new Error("cannot clean up a running job");
rmSync(jobPath(jobId), { recursive: true, force: false });
return JSON.stringify({ job_id: jobId, cleaned: true, previous_state: status.state });
}
async function runArgv(
label: string,
argv: string[],
+12
View File
@@ -38,6 +38,15 @@ describe("secure tool configuration", () => {
expect(cfg.enabledTools).toContain("unraid_files_inventory");
expect(cfg.enabledTools).not.toContain("unraid_system_shell");
});
test("an enabled critical shell also exposes bounded asynchronous jobs", () => {
const cfg = parseConfig(
"MUA_API_KEY=test\nMUA_ENABLED_TOOLS=unraid_system_shell\n",
);
expect(cfg.enabledTools).toContain("unraid_system_job_start");
expect(cfg.enabledTools).toContain("unraid_system_job_status");
expect(cfg.enabledTools).toContain("unraid_system_job_cleanup");
});
});
describe("secret handling", () => {
@@ -55,6 +64,9 @@ describe("secret handling", () => {
describe("risk classification", () => {
test("classifies root shell and container changes as critical", () => {
expect(getToolRisk("unraid_system_shell")).toBe("critical");
expect(getToolRisk("unraid_system_job_start")).toBe("critical");
expect(getToolRisk("unraid_system_job_cleanup")).toBe("critical");
expect(getToolRisk("unraid_system_job_status")).toBe("read");
expect(getToolRisk("unraid_docker_modify")).toBe("critical");
expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical");
expect(getToolRisk("unraid_docker_restart")).toBe("write");
+43
View File
@@ -20,6 +20,9 @@ import {
connectionTest,
validateName,
runShell,
startShellJob,
shellJobStatus,
cleanupShellJob,
runReadOnlyCommand,
runStatusHelper,
searchCommunityApps,
@@ -43,6 +46,8 @@ const CRITICAL_TOOLS = new Set([
"unraid_docker_update_verified_batch",
"unraid_docker_rebuild",
"unraid_system_shell",
"unraid_system_job_start",
"unraid_system_job_cleanup",
"unraid_ca_install",
]);
const WRITE_TOOLS = new Set([
@@ -634,6 +639,44 @@ export const TOOLS: ToolDef[] = [
return runShell(command, timeout);
},
},
{
name: "unraid_system_job_start",
description:
"CRITICAL: Start one explicitly authorized long-running unrestricted shell command on Unraid as an asynchronous job. Use this instead of unraid_system_shell when work may exceed an HTTP/tool timeout. Returns a job_id immediately; poll only with unraid_system_job_status and reserve calls for verification and cleanup.",
inputSchema: {
type: "object",
properties: {
command: str("Long-running shell command to execute on Unraid via /bin/sh"),
},
required: ["command"],
additionalProperties: false,
},
handler: (a) => startShellJob(String(a["command"] ?? "")),
},
{
name: "unraid_system_job_status",
description:
"Poll one asynchronous Unraid shell job by job_id. Returns running/finished state, exit code and compact stdout/stderr tails. Do not start a duplicate job while this reports running.",
inputSchema: {
type: "object",
properties: { job_id: str("Job identifier returned by unraid_system_job_start") },
required: ["job_id"],
additionalProperties: false,
},
handler: (a) => shellJobStatus(String(a["job_id"] ?? "")),
},
{
name: "unraid_system_job_cleanup",
description:
"CRITICAL: Remove the private MUA metadata/log directory for one completed asynchronous job. This never removes the command's own output files. Refuses cleanup while the job is running.",
inputSchema: {
type: "object",
properties: { job_id: str("Completed job identifier") },
required: ["job_id"],
additionalProperties: false,
},
handler: (a) => cleanupShellJob(String(a["job_id"] ?? "")),
},
];
export function toolByName(name: string): ToolDef | undefined {