feat: add asynchronous Unraid jobs
This commit is contained in:
@@ -76,10 +76,10 @@ Oder manuell:
|
||||
|
||||
```bash
|
||||
# .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
|
||||
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
|
||||
@@ -94,7 +94,7 @@ Der Service startet automatisch bei jedem Boot (SysVinit).
|
||||
|
||||
```bash
|
||||
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` |
|
||||
| **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` |
|
||||
| **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
|
||||
`tools/list` und können nicht aufgerufen werden (→ `ERROR: Tool disabled`).
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mua",
|
||||
"version": "2026.08.24.r021",
|
||||
"version": "2026.08.24.r022",
|
||||
"description": "Mikes Unraid Agent - MCP over HTTP (Streamable HTTP) for Unraid",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
+9
-4
@@ -2,13 +2,13 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "mua">
|
||||
<!ENTITY author "Michael">
|
||||
<!ENTITY version "2026.08.24.r021">
|
||||
<!ENTITY version "2026.08.24.r022">
|
||||
<!ENTITY launch "Settings/mua">
|
||||
<!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 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 txzSHA256 "f114ef5be9729bb01362c1d9e165c7055ff67325275ac6562aceba28c2a1fcd5">
|
||||
<!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 "ac981953fe30d468b22f3febc65c7bdae473393824d4dd4e5a015f2fe18e4f86">
|
||||
]>
|
||||
|
||||
<PLUGIN name="&name;"
|
||||
@@ -23,6 +23,11 @@
|
||||
>
|
||||
|
||||
<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
|
||||
- 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.
|
||||
@@ -140,7 +145,7 @@ Das .txz enthält:
|
||||
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>
|
||||
<SHA256>&txzSHA256;</SHA256>
|
||||
</FILE>
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ cat > "${BUILD_DIR}/install/slack-desc" << DESCEOF
|
||||
|
|
||||
|${PKG_NAME} - Mikes Unraid Agent
|
||||
|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
|
||||
|
|
||||
|Runtime: TypeScript (Bun Runtime, kompiliertes Binary)
|
||||
|
||||
+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