feat: add asynchronous Unraid jobs
This commit is contained in:
1 parent
b4b83c2797
commit
88643de738
9 files changed
+165
-11
No files matched your search
+12
@@ -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
@@ -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[],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user