feat: add asynchronous Unraid jobs

This commit is contained in:
Mikei386
2026-08-24 14:52:19 +02:00
parent b4b83c2797
commit 88643de738
9 changed files with 165 additions and 11 deletions
+4 -4
View File
@@ -76,10 +76,10 @@ Oder manuell:
```bash ```bash
# .txz von Gitea laden # .txz von Gitea laden
curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r021-x86_64-1.txz curl -O http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz
# Installieren # Installieren
upgradepkg --install-new mua-2026.08.24.r021-x86_64-1.txz upgradepkg --install-new mua-2026.08.24.r022-x86_64-1.txz
``` ```
### 2. Service starten ### 2. Service starten
@@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
```bash ```bash
curl http://192.168.1.2:3002/health curl http://192.168.1.2:3002/health
# → {"status":"ok","version":"2026.08.24.r021","auth":"required"} # → {"status":"ok","version":"2026.08.24.r022","auth":"required"}
``` ```
--- ---
@@ -215,7 +215,7 @@ Zusätzlich kann jedes Werkzeug einzeln nach Risikostufe freigegeben werden:
| **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` | | **Docker (16)** | `unraid_docker_list`, `unraid_docker_inspect`, `unraid_docker_logs`, `unraid_docker_analyze_logs`, `unraid_docker_processes`, `unraid_docker_stats`, `unraid_docker_info`, `unraid_docker_update_status`, `unraid_docker_start`, `unraid_docker_stop`, `unraid_docker_restart`, `unraid_docker_create`, `unraid_docker_modify`, `unraid_docker_update`, `unraid_docker_update_verified_batch`, `unraid_docker_rebuild` |
| **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` | | **Community Applications (3)** | `unraid_ca_search`, `unraid_ca_install_preview`, `unraid_ca_install` |
| **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` | | **Netzwerk (6)** | `unraid_network_inventory`, `unraid_network_list`, `unraid_network_inspect`, `unraid_network_host_state`, `unraid_network_audit_tcp`, `unraid_network_lan_probe` |
| **System (10)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell` | | **System (13)** | `unraid_system_health`, `unraid_storage_status`, `unraid_disk_health`, `unraid_notifications_list`, `unraid_shares_list`, `unraid_share_inspect`, `unraid_files_inventory`, `unraid_system_connection_test`, `unraid_system_shell_readonly`, `unraid_system_shell`, `unraid_system_job_start`, `unraid_system_job_status`, `unraid_system_job_cleanup` |
Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in Deaktivierte Tools werden vom MCP-Server gefiltert — sie erscheinen nicht in
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`). `tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mua", "name": "mua",
"version": "2026.08.24.r021", "version": "2026.08.24.r022",
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid", "description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+9 -4
View File
@@ -2,13 +2,13 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "mua"> <!ENTITY name "mua">
<!ENTITY author "Michael"> <!ENTITY author "Michael">
<!ENTITY version "2026.08.24.r021"> <!ENTITY version "2026.08.24.r022">
<!ENTITY launch "Settings/mua"> <!ENTITY launch "Settings/mua">
<!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg"> <!ENTITY pluginURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/plugin/mua.plg">
<!ENTITY pluginLOC "/boot/config/plugins/&name;"> <!ENTITY pluginLOC "/boot/config/plugins/&name;">
<!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;"> <!ENTITY emhttpLOC "/usr/local/emhttp/plugins/&name;">
<!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r021-x86_64-1.txz"> <!ENTITY txzURL "http://192.168.1.2:4000/michael/MUA-Mikes-Unraid-Agent/raw/branch/main/dist/mua-2026.08.24.r022-x86_64-1.txz">
<!ENTITY txzSHA256 "f114ef5be9729bb01362c1d9e165c7055ff67325275ac6562aceba28c2a1fcd5"> <!ENTITY txzSHA256 "ac981953fe30d468b22f3febc65c7bdae473393824d4dd4e5a015f2fe18e4f86">
]> ]>
<PLUGIN name="&name;" <PLUGIN name="&name;"
@@ -23,6 +23,11 @@
> >
<CHANGES> <CHANGES>
### 2026.08.24.r022
- Allgemeine asynchrone Root-Jobs mit getrennten Werkzeugen für Start, kompakten Status und Aufräumen verhindern HTTP-Timeouts bei langen autorisierten Arbeiten.
- Bestehende Freigaben der kritischen Root-Shell erhalten dieselbe Berechtigungsklasse automatisch; Nur-Lese-Profile bleiben unverändert.
- Jobstatus enthält nur Zustand, Exit-Code und begrenzte, redigierte Log-Enden. Kommandoargumente und mögliche Secrets werden nicht zurückgegeben.
### 2026.08.24.r021 ### 2026.08.24.r021
- Die gefilterte Sammlungssuche beendet den Abstieg an einem passenden Verzeichnis. Episodenordner fluten dadurch nicht mehr das begrenzte Erkennungsergebnis. - Die gefilterte Sammlungssuche beendet den Abstieg an einem passenden Verzeichnis. Episodenordner fluten dadurch nicht mehr das begrenzte Erkennungsergebnis.
- Der zweite Aufruf auf den gefundenen Sammlungspfad bleibt unverändert und liefert die vollständige gebündelte Bestandsaufnahme. - Der zweite Aufruf auf den gefundenen Sammlungspfad bleibt unverändert und liefert die vollständige gebündelte Bestandsaufnahme.
@@ -140,7 +145,7 @@ Das .txz enthält:
install/doinst.sh (läuft nach Installation) install/doinst.sh (läuft nach Installation)
=========================================== ===========================================
--> -->
<FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r021-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0"> <FILE Name="/boot/config/plugins/&name;/mua-2026.08.24.r022-x86_64-1.txz" Run="upgradepkg --install-new" Mode="755" Min="7.0.0">
<URL>&txzURL;</URL> <URL>&txzURL;</URL>
<SHA256>&txzSHA256;</SHA256> <SHA256>&txzSHA256;</SHA256>
</FILE> </FILE>
+1 -1
View File
@@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
| |
|${PKG_NAME} - Mikes Unraid Agent |${PKG_NAME} - Mikes Unraid Agent
|MCP over HTTP (Streamable HTTP) Server für Unraid. |MCP over HTTP (Streamable HTTP) Server für Unraid.
|35 Tools: Docker, Community Applications, Netzwerk und Unraid-System. |Werkzeuge für Docker, Community Applications, Netzwerk und Unraid-System.
|Port: 3002, Endpunkt: /mcp |Port: 3002, Endpunkt: /mcp
| |
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary) |Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
+12
View File
@@ -116,6 +116,18 @@ export function parseConfig(content: string): MUAConfig {
) { ) {
cfg.enabledTools.push("unraid_files_inventory"); 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; return cfg;
} }
+83 -1
View File
@@ -11,10 +11,11 @@
import { spawn } from "bun"; import { spawn } from "bun";
import { createConnection, type Socket } from "net"; import { createConnection, type Socket } from "net";
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
// ── Konstanten ────────────────────────────────────────────────────────── // ── Konstanten ──────────────────────────────────────────────────────────
export const MUA_SERVER_NAME = "mua"; 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 MUA_PROTOCOL_VERSION = "2025-03-26";
export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php"; export const PHP_HELPER = "/usr/local/bin/unraid-docker-mcp-helper.php";
export const STATUS_HELPER = "/usr/local/bin/unraid-mcp-status-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( async function runArgv(
label: string, label: string,
argv: string[], argv: string[],
+12
View File
@@ -38,6 +38,15 @@ describe("secure tool configuration", () => {
expect(cfg.enabledTools).toContain("unraid_files_inventory"); expect(cfg.enabledTools).toContain("unraid_files_inventory");
expect(cfg.enabledTools).not.toContain("unraid_system_shell"); 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", () => { describe("secret handling", () => {
@@ -55,6 +64,9 @@ describe("secret handling", () => {
describe("risk classification", () => { describe("risk classification", () => {
test("classifies root shell and container changes as critical", () => { test("classifies root shell and container changes as critical", () => {
expect(getToolRisk("unraid_system_shell")).toBe("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_modify")).toBe("critical");
expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical"); expect(getToolRisk("unraid_docker_update_verified_batch")).toBe("critical");
expect(getToolRisk("unraid_docker_restart")).toBe("write"); expect(getToolRisk("unraid_docker_restart")).toBe("write");
+43
View File
@@ -20,6 +20,9 @@ import {
connectionTest, connectionTest,
validateName, validateName,
runShell, runShell,
startShellJob,
shellJobStatus,
cleanupShellJob,
runReadOnlyCommand, runReadOnlyCommand,
runStatusHelper, runStatusHelper,
searchCommunityApps, searchCommunityApps,
@@ -43,6 +46,8 @@ const CRITICAL_TOOLS = new Set([
"unraid_docker_update_verified_batch", "unraid_docker_update_verified_batch",
"unraid_docker_rebuild", "unraid_docker_rebuild",
"unraid_system_shell", "unraid_system_shell",
"unraid_system_job_start",
"unraid_system_job_cleanup",
"unraid_ca_install", "unraid_ca_install",
]); ]);
const WRITE_TOOLS = new Set([ const WRITE_TOOLS = new Set([
@@ -634,6 +639,44 @@ export const TOOLS: ToolDef[] = [
return runShell(command, timeout); 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 { export function toolByName(name: string): ToolDef | undefined {