diff --git a/README.md b/README.md index 8fec920..7c57d3f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ WireGuard-Isolation. - Open WebUI als einzige normale Oberfläche - SearXNG/Web-MCP ohne externen API-Schlüssel - zentrale MCP-Werkzeugebene: getrennte Container für Athena-Plattformwissen, - Web, GitHub, HA, ARR, Unraid, Navidrome und Sandbox, gemeinsam nutzbar durch Open WebUI und andere + den kontrollierten Athena Operator, Web, GitHub, HA, ARR, Unraid, Navidrome + und Sandbox, gemeinsam nutzbar durch Open WebUI und andere Clients - KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar - KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed @@ -56,7 +57,7 @@ Neustart an; danach wird derselbe Befehl erneut ausgeführt. | XTTS-v2 | nur Docker-intern, RTX 3060 | primäre mehrsprachige Sprachausgabe | | TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback | | Piper | nur Docker-intern, CPU | ausfallsichere deutsche Ersatzstimme | -| MCP-Tool-Stack | nur Docker-intern | Web, GitHub, Home Assistant, ARR, Unraid und Navidrome | +| MCP-Tool-Stack | nur Docker-intern | Athena-Kontext, Athena Operator, Web, GitHub, Home Assistant, ARR, Unraid und Navidrome | XTTS-v2, TTS-Gateway, Piper-Fallback und der FLUX.2-Klein-Hot-Swap sind reproduzierbare Kerndienste; STT @@ -78,6 +79,20 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten. ## Dokumentation +### API-Schnellreferenz + +Für Zettelrobbe und andere OpenAI-kompatible Clients gilt im Heimnetz: + +```text +Base URL: http://192.168.1.212:8081/v1 +API-Key: Inhalt von /etc/mike-ai/router-api-key auf Athena +``` + +Den Schlüssel auf Athena ausschließlich lokal mit +`sudo cat /etc/mike-ai/router-api-key` anzeigen und direkt in den Secret-Store +des Clients kopieren. Er gehört niemals in Git, eine URL oder einen Chat. Die +entsprechende Open-WebUI-Adresse auf Port `8080` ist keine API-Basisadresse. + - [`docs/PLATFORM_OVERVIEW.md`](docs/PLATFORM_OVERVIEW.md) – kurze Gesamtsicht - [`docs/QWEN_OPERATOR_CONTEXT.md`](docs/QWEN_OPERATOR_CONTEXT.md) – ausführliches Kontextpaket für das lokale Operator-Modell - [`docs/PLATFORM_CONTEXT_MCP.md`](docs/PLATFORM_CONTEXT_MCP.md) – profilunabhängiges Plattformwissen und kontrollierte Dokumentationspflege diff --git a/config/athena-operator.env.example b/config/athena-operator.env.example new file mode 100644 index 0000000..ece31fa --- /dev/null +++ b/config/athena-operator.env.example @@ -0,0 +1,9 @@ +ATHENA_OPERATOR_STACK=/opt/mike-ai/stack +ATHENA_OPERATOR_REPOSITORY=/data/mike-ai-operator/repository +ATHENA_OPERATOR_STATE=/data/mike-ai-operator/state +ATHENA_OPERATOR_MODELS=/data/models +ATHENA_OPERATOR_SOCKET=/run/mike-ai-operator/operator.sock +ATHENA_OPERATOR_GID=10003 +ATHENA_OPERATOR_GIT_REMOTE=git@192.168.1.2:michael/AI-Profile-Router.git +ATHENA_OPERATOR_GIT_NAME=Athena Operator +ATHENA_OPERATOR_GIT_EMAIL=athena-operator@localhost diff --git a/config/operator-system-prompt.txt b/config/operator-system-prompt.txt index 7779217..772d9d8 100644 --- a/config/operator-system-prompt.txt +++ b/config/operator-system-prompt.txt @@ -23,6 +23,15 @@ proposal and receiving explicit user approval. A local docs update is not complete until Git commit/push and the refreshed recovery kit are separately verified. +For implementation and operation of Athena itself, use the Athena Operator MCP. +It is the single controlled management interface for versioned source changes, +Docker deployment, model downloads and benchmarks, Git publication and recovery +creation. Read and inspect directly; for every mutation first request a bounded +preview, show that preview to the user, and execute only the exact returned ticket +after explicit confirmation in a later message. Never claim that a preview was +executed. The Operator is intentionally not an arbitrary root shell and cannot +change SSH, networking, WireGuard, boot, kernel, disks, reboot or shutdown. + Athena is physically remote and normally has no KVM or on-site recovery. Never shut down, reboot, power off, alter SSH, lan0, firewall, routing, WireGuard, kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts, diff --git a/dev/test_athena_operator.py b/dev/test_athena_operator.py new file mode 100644 index 0000000..276202e --- /dev/null +++ b/dev/test_athena_operator.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Offline safety and workflow tests for the Athena Operator executor.""" + +from __future__ import annotations + +import importlib.util +import json +import tempfile +import time +import unittest +from pathlib import Path + + +SOURCE = Path(__file__).parents[1] / "platform" / "operator" / "athena_operatord.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("athena_operatord_tested", SOURCE) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class OperatorTests(unittest.TestCase): + def setUp(self): + self.module = load_module() + self.temporary = tempfile.TemporaryDirectory() + root = Path(self.temporary.name) + self.repo, self.stack = root / "repository", root / "stack" + self.models, self.state = root / "models", root / "state" + for path in (self.repo, self.stack, self.models, self.state): + path.mkdir() + (self.repo / ".git").mkdir() + (self.repo / "docs").mkdir() + (self.stack / "docs").mkdir() + for base in (self.repo, self.stack): + (base / "docs" / "test.md").write_text("before\n", encoding="utf-8") + self.module.REPOSITORY = self.repo.resolve() + self.module.STACK = self.stack.resolve() + self.module.MODELS = self.models.resolve() + self.module.STATE = self.state.resolve() + self.module.audit = lambda *args, **kwargs: None + self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": "ok"} + + def tearDown(self): + self.temporary.cleanup() + + def prepare_file(self): + before = self.module.sha((self.repo / "docs" / "test.md").read_bytes()) + return self.module.prepare({ + "operation": "file_update", + "payload": {"files": [{"path": "docs/test.md", "content": "after\n", "expected_sha256": before}]}, + }) + + def test_preview_changes_nothing(self): + proposal = self.prepare_file() + self.assertEqual((self.repo / "docs" / "test.md").read_text(), "before\n") + self.assertIn("-before", proposal["preview"]) + self.assertIn("+after", proposal["preview"]) + + def test_exact_later_confirmation_updates_repo_and_deploy_tree(self): + proposal = self.prepare_file() + result = self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + self.assertEqual((self.repo / "docs" / "test.md").read_text(), "after\n") + self.assertEqual((self.stack / "docs" / "test.md").read_text(), "after\n") + self.assertEqual(result["operation"], "file_update") + with self.assertRaises(ValueError): + self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + + def test_wrong_confirmation_and_expired_ticket_are_rejected(self): + proposal = self.prepare_file() + with self.assertRaises(PermissionError): + self.module.execute({"ticket": proposal["ticket"], "confirmation": "yes"}) + path = self.state / "pending" / f"{proposal['ticket']}.json" + record = json.loads(path.read_text()) + record["expires"] = int(time.time()) - 1 + self.module.json_write(path, record) + with self.assertRaises(PermissionError): + self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + + def test_source_drift_blocks_apply(self): + proposal = self.prepare_file() + (self.repo / "docs" / "test.md").write_text("drift\n") + with self.assertRaises(RuntimeError): + self.module.execute({"ticket": proposal["ticket"], "confirmation": proposal["required_confirmation"]}) + + def test_remote_access_and_power_operations_do_not_exist(self): + self.assertNotIn("shell", self.module.ALLOWED_OPERATIONS) + for operation in ("shutdown", "reboot", "ssh", "network", "command"): + with self.assertRaises(ValueError): + self.module.normalise_operation(operation, {}) + + def test_wireguard_gateway_cannot_be_stopped(self): + with self.assertRaises(PermissionError): + self.module.normalise_operation("container_action", {"action": "stop", "containers": ["mike-ai-wireguard-gateway"]}) + + def test_only_huggingface_model_downloads_are_accepted(self): + with self.assertRaises(PermissionError): + self.module.normalise_operation("model_download", {"url": "https://evil.invalid/model.gguf", "destination": "model.gguf"}) + payload, _ = self.module.normalise_operation("model_download", {"url": "https://huggingface.co/org/repo/resolve/main/model.gguf", "destination": "qwen/model.gguf"}) + self.assertEqual(payload["destination"], "qwen/model.gguf") + + def test_openwebui_sync_is_structured_and_requires_a_ticket(self): + payload, preview = self.module.normalise_operation("openwebui_sync", {}) + self.assertEqual(payload, {}) + self.assertIn("Synchronise", preview) + + def test_git_publish_preview_is_bound_to_reviewed_worktree(self): + calls = [] + def fake_run(argv, **kwargs): + calls.append(argv) + if argv[:3] == ["git", "status", "--short"]: + return {"argv": argv, "exit_code": 0, "output": " M compose.yaml"} + if argv[:3] == ["git", "diff", "--stat"]: + return {"argv": argv, "exit_code": 0, "output": " compose.yaml | 2 +-"} + return {"argv": argv, "exit_code": 0, "output": "ok"} + self.module.run = fake_run + payload, preview = self.module.normalise_operation("git_publish", {"message": "Update Athena platform"}) + self.assertEqual(payload["reviewed_status"], "M compose.yaml") + self.assertIn("compose.yaml | 2 +-", preview) + + def test_git_publish_rejects_empty_repository(self): + self.module.run = lambda argv, **kwargs: {"argv": argv, "exit_code": 0, "output": ""} + with self.assertRaises(RuntimeError): + self.module.normalise_operation("git_publish", {"message": "Update Athena platform"}) + + def test_path_traversal_and_protected_paths_are_rejected(self): + for path in ("../etc/passwd", ".git/config", "/etc/passwd", "secrets/key"): + with self.subTest(path=path), self.assertRaises((ValueError, PermissionError)): + self.module.safe_relative(path) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/dev/test_athena_terminal_mcp.py b/dev/test_athena_terminal_mcp.py deleted file mode 100644 index 80c982c..0000000 --- a/dev/test_athena_terminal_mcp.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""Offline abuse and capability tests for the bounded Athena terminal MCP.""" - -from __future__ import annotations - -import importlib.util -import tempfile -import unittest -from pathlib import Path - - -SOURCE = Path(__file__).parents[1] / "platform" / "mcp" / "athena_terminal_mcp.py" - - -def load_module(): - spec = importlib.util.spec_from_file_location("athena_terminal_mcp_tested", SOURCE) - module = importlib.util.module_from_spec(spec) - assert spec.loader is not None - spec.loader.exec_module(module) - return module - - -class AthenaTerminalTests(unittest.TestCase): - def setUp(self): - self.module = load_module() - self.temporary = tempfile.TemporaryDirectory() - root = Path(self.temporary.name) - self.workspace = root / "workspace" - self.runtime = root / "runtime" - self.workspace.mkdir() - self.runtime.mkdir() - (self.workspace / "README.md").write_text("Athena evidence\n", encoding="utf-8") - (self.workspace / "valid.json").write_text('{"ok":true}\n', encoding="utf-8") - self.module.WORKSPACE_ROOT = self.workspace.resolve() - self.module.RUNTIME_ROOT = self.runtime.resolve() - self.module.ALLOWED_ROOTS = ( - self.module.WORKSPACE_ROOT, - self.module.RUNTIME_ROOT, - ) - - def tearDown(self): - self.temporary.cleanup() - - def test_allowed_read_works(self): - result = self.module.run_command( - {"program": "cat", "arguments": ["README.md"], "working_directory": "workspace"} - ) - self.assertEqual(result["exit_code"], 0) - self.assertEqual(result["output"], "Athena evidence\n") - self.assertTrue(result["read_only"]) - - def test_power_remote_shell_and_admin_programs_are_blocked(self): - for program in ( - "shutdown", "reboot", "poweroff", "ssh", "scp", "bash", - "python3", "docker", "systemctl", "curl", "wget", "sudo", - ): - with self.subTest(program=program), self.assertRaises(PermissionError): - self.module.validate_arguments(program, [], self.workspace) - - def test_shell_syntax_is_blocked(self): - for value in ("$(id)", "${HOME}", "`id`", "a|b", "x;y", ">file", "a&&b"): - with self.subTest(value=value), self.assertRaises(ValueError): - self.module.clean_scalar(value) - - def test_path_escape_and_symlink_escape_are_blocked(self): - with self.assertRaises(PermissionError): - self.module.safe_path("/etc/passwd", self.workspace) - (self.workspace / "escape").symlink_to("/etc/passwd") - with self.assertRaises(PermissionError): - self.module.safe_path("escape", self.workspace) - - def test_secret_like_path_is_blocked(self): - secret = self.workspace / "credentials" - secret.write_text("nope", encoding="utf-8") - with self.assertRaises(PermissionError): - self.module.safe_path("credentials", self.workspace) - - def test_ripgrep_preprocessor_and_find_are_not_available(self): - with self.assertRaises(ValueError): - self.module.validate_arguments("rg", ["--pre", "sh", "x"], self.workspace) - with self.assertRaises(PermissionError): - self.module.validate_arguments("find", ["."], self.workspace) - with self.assertRaises(ValueError): - self.module.validate_arguments("grep", ["-R", "Athena", "."], self.workspace) - - def test_validation_parses_without_execution(self): - result = self.module.validate_source({"path": "valid.json", "kind": "auto"}) - self.assertTrue(result["valid"]) - self.assertFalse(result["executed"]) - self.assertFalse(result["modified"]) - - def test_policy_states_missing_capabilities(self): - unavailable = " ".join(self.module.policy()["unavailable"]) - for word in ("SSH", "shutdown", "reboot", "Docker", "network"): - self.assertIn(word, unavailable) - - -if __name__ == "__main__": - unittest.main(verbosity=2) diff --git a/dev/test_openwebui_filters.py b/dev/test_openwebui_filters.py index 62d4511..e4c6bf7 100644 --- a/dev/test_openwebui_filters.py +++ b/dev/test_openwebui_filters.py @@ -170,12 +170,12 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(result["tool_ids"], ["server:mcp:athena-platform"]) - async def test_athena_terminal_is_selected_only_for_explicit_terminal_work(self): + async def test_athena_operator_is_selected_for_platform_work(self): result = await self._select( - "Zeige mir im Athena-Terminal mit ls die Dateien des KI-Hosts." + "Baue und deploye auf Athena einen neuen MCP-Container." ) self.assertEqual( - result["tool_ids"], ["server:mcp:athena-terminal-local"] + result["tool_ids"], ["server:mcp:athena-operator-local"] ) async def test_mcp_build_from_github_gets_source_and_platform_context(self): @@ -184,7 +184,7 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual( result["tool_ids"], - ["server:mcp:github-local", "server:mcp:athena-platform"], + ["server:mcp:github-local", "server:mcp:athena-operator-local"], ) async def test_github_and_explicit_web_are_bounded_to_two(self): diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index e0aaec0..5893fc5 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -13,7 +13,7 @@ | Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional | | GitHub-MCP | offizieller `github/github-mcp-server` 1.10.1, vier read-only Werkzeuge | eigener optionaler Container hinter Streamable-HTTP-Brücke | optional | | Platform Context MCP | Athena-/MikeAI-Wissen, begrenzter Laufzeitsnapshot und kontrollierte Dokumentationspflege | eigener Container ohne Docker-Socket, Shell, Egress oder Secrets | Kern | -| Athena Terminal MCP | gezielte Lesebefehle und Syntaxprüfung in Stack/Snapshot | eigener read-only Container ohne Host-Shell, Docker, SSH, Egress, Secrets oder Power-Befehle | Kern | +| Athena Operator MCP | Entwicklung und vollständiger Betrieb der KI-Plattform mit gebundenen Freigaben | unprivilegierte MCP-Fassade plus rootseitiger strukturierter Executor; keine freie Shell | Kern | | Operator-Kontext | `docs/QWEN_OPERATOR_CONTEXT.md` plus `config/operator-system-prompt.txt` | versionierte Selbstbeschreibung und Sicherheitsregeln für Qwen | Kern | | Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional | | Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional | diff --git a/docs/CURRENT_REFERENCE.md b/docs/CURRENT_REFERENCE.md index d87b206..3b6a2d2 100644 --- a/docs/CURRENT_REFERENCE.md +++ b/docs/CURRENT_REFERENCE.md @@ -158,7 +158,7 @@ Der isolierte Eignungs- und Ausfalltest ist in Aktuell existieren funktionale Adapter für: - Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege -- Athena Terminal: eng begrenzte Lesebefehle und Syntaxprüfung in Stack/Snapshot +- Athena Operator: Entwicklung, Docker/MCP/Modelle, Tests, Git und Recovery - Websuche - Home Assistant - Sonarr/Radarr @@ -187,12 +187,13 @@ Ein root-eigener Minutentimer erzeugt nur einen begrenzten Laufzeitsnapshot. Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt. -Der ergänzende Athena Terminal MCP ist kein Host-Terminal. Er sieht nur den -read-only Stack und Laufzeitsnapshot und besitzt eine Positivliste weniger -Lesebefehle. SSH/SCP, Docker, Netzwerkclients, Interpreter, `systemctl`, -Prozesssignale, Reboot, Shutdown, Secrets und Schreibzugriffe sind nicht -verfügbar. Der Auto Tool Selector lädt ihn nur bei ausdrücklich auf Athena -bezogener Terminal-/Shell-Arbeit. +Der Athena Operator MCP ersetzt die frühere begrenzte Terminal-Fassade. Er ist +die zusammenhängende Bedienebene, mit der Qwen die KI-Plattform selbst +weiterentwickeln und betreiben kann. Quellenlesen, Dateiänderungen, Tests, +Compose-Deployments, Containeraktionen, Modell-Downloads, Benchmarks, +Git-Publishing und Recovery sind strukturiert verfügbar. Zustandsänderungen +benötigen immer Vorschau und ein content-gebundenes Approval-Ticket. Ein freies +Root-Terminal sowie Remotezugang, Netzwerk/SSH/Boot/Power bleiben getrennt. Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt. diff --git a/docs/DISASTER_RECOVERY.md b/docs/DISASTER_RECOVERY.md index 5f06867..a42e920 100644 --- a/docs/DISASTER_RECOVERY.md +++ b/docs/DISASTER_RECOVERY.md @@ -57,7 +57,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet - [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home Assistant, ARR, Navidrome, Unraid read-only, Athena-Plattformwissen und - das begrenzte Athena-Terminal korrekt + den Athena Operator korrekt - [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt - [ ] SearXNG und TinySearch gesund @@ -79,8 +79,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. System-Prompt entsprechen dem wiederhergestellten Stand - [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft -- [ ] Athena-Terminal gesund; erlaubter Leseaufruf erfolgreich; SSH, Docker, - Reboot, Shutdown und Pfadausbruch in Negativtests verweigert +- [ ] Athena Operator und rootseitiger Executor gesund; Lesen und Preview + erfolgreich; falsches/abgelaufenes Ticket, Pfadausbruch, WireGuard-Stopp, + freie Befehle, SSH, Reboot und Shutdown in Negativtests verweigert - [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und der Recovery-Koffer wurde danach neu erzeugt @@ -110,8 +111,8 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden. - [ ] Router nur aus erlaubtem Netz erreichbar - [ ] Dienste laufen mit minimalen Rechten - [ ] Environment-Dateien Modus 0600 -- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena-Terminal besitzt nur - die dokumentierte Positivliste und zwei read-only Mounts +- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena Operator besitzt nur + strukturierte, ticketgebundene Plattformoperationen - [ ] Schreibaktionen verlangen Vorschau und Approval Ticket - [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt - [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 462978d..ec47159 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -143,14 +143,33 @@ zentralen Secret-, Prompt-Injection- und Tool-Output-Filter bleiben aktiv. Clients verbinden sich mit: ```text -http://:8081/v1 +http://192.168.1.212:8081/v1 ``` +Dies ist die OpenAI-kompatible Router-API für Zettelrobbe, Hermes und andere +Clients im Heimnetz. `http://192.168.1.212:8080` ist ausschließlich +Open WebUI und darf nicht als API-Basisadresse eingetragen werden. + Als API-Key verwenden sie den Inhalt von `/etc/mike-ai/router-api-key` über Bearer-Authentifizierung. Der Key gehört in den Secret-Store des Clients, nicht in Chat, Repository oder URL. Eine Rotation erfolgt atomar durch Ersetzen der Datei und Neustart des Routerdienstes. +Nur lokal auf Athena anzeigen und direkt in den Zielclient kopieren: + +```bash +sudo cat /etc/mike-ai/router-api-key +``` + +Ein einfacher Verbindungstest ohne Ausgabe des Schlüssels: + +```bash +ROUTER_API_KEY=$(sudo cat /etc/mike-ai/router-api-key) +curl -fsS http://192.168.1.212:8081/v1/models \ + -H "Authorization: Bearer $ROUTER_API_KEY" +unset ROUTER_API_KEY +``` + Sie sollen nicht direkt Port 8080 verwenden, weil sie sonst Profilumschaltung, Vision, Bildgenerierung, STT und TTS umgehen. diff --git a/docs/PLATFORM_OVERVIEW.md b/docs/PLATFORM_OVERVIEW.md index 12cf300..42a052c 100644 --- a/docs/PLATFORM_OVERVIEW.md +++ b/docs/PLATFORM_OVERVIEW.md @@ -96,11 +96,13 @@ Der offizielle GitHub-MCP bietet nur vier Werkzeuge: Andere GitHub-Werkzeuge sowie Schreibzugriffe sind serverseitig deaktiviert. -Für gezielte Beweissuche im Athena-Stack existiert zusätzlich eine begrenzte -Terminal-Fassade. Sie ist kein allgemeiner Shell-MCP: Nur fest erlaubte -Lesebefehle gegen zwei read-only Mounts sind möglich. Docker, SSH, Netzwerk, -Secrets, Service-/Power-Steuerung und jede Form von Schreiben bleiben außerhalb -dieser Vertrauensgrenze. +Für Entwicklung und Betrieb der KI-Plattform existiert ein zentraler Athena +Operator MCP. Eine unprivilegierte MCP-Fassade spricht ausschließlich über +einen Unix-Socket mit einem rootseitigen strukturierten Executor. Dadurch kann +Qwen MCPs, Docker-Dienste, Modelle, Profile, OpenWebUI, Tests, Git und Recovery +selbst pflegen, erhält aber keinen freien Root-Befehl. Jede Mutation wird als +gebundene Vorschau vorbereitet und erst nach ausdrücklicher späterer Freigabe +ausgeführt. Netzwerk-, SSH-, Boot- und Powerzugriffe sind nicht Teil davon. ## Verbindliche Quellen diff --git a/docs/QWEN_OPERATOR_CONTEXT.md b/docs/QWEN_OPERATOR_CONTEXT.md index c700743..b7f59f5 100644 --- a/docs/QWEN_OPERATOR_CONTEXT.md +++ b/docs/QWEN_OPERATOR_CONTEXT.md @@ -226,7 +226,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht. | Bereich | Aufgabe | Rechte | |---|---|---| | Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval | -| Athena Terminal | gezieltes Lesen und Syntaxprüfung in Stack/Snapshot | strikt read-only; kein Host-Zugriff | +| Athena Operator | vollständige Entwicklung und Betrieb der KI-Plattform | Lesen direkt; Änderungen nur Preview/Ticket/Approval | | Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only | | GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools | | Home Assistant | Zustände, Historie, Diagnose, begrenzte YAML-Abläufe | Lesen; Schreiben nur Preview/Approval | @@ -235,12 +235,16 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht. | Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard | | MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren | -`Athena Terminal` ist trotz seines Namens keine allgemeine Shell. Das Werkzeug -stellt ausschließlich `ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`, -`sha256sum`, `grep`, `rg`, `sed`, `du`, `df` und reine Syntaxprüfung bereit. -Es kann weder Dateien ändern noch Docker, SSH, Netzwerk, Dienste, Prozesse, -Reboot oder Shutdown steuern. Fordere keine Umgehung dieser Grenze an und -behaupte nach einem Leseaufruf niemals, eine Änderung deployed zu haben. +`Athena Operator` ist die zentrale Arbeitsumgebung für Änderungen an Athena. +Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von +Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der +MikeAI-Container, Laden und Prüfen von Modellen, Starten versionierter +Benchmarks, Git-Publishing und Recovery. Änderungen erfolgen stets in zwei +getrennten Phasen: vollständige Vorschau erzeugen, dem Benutzer zeigen und +stoppen; erst nach dessen späterer exakter Ticketbestätigung ausführen. Ein +freier Shellbefehl, SSH, Netzwerk-/WireGuard-/Firewall-Umbau, Boot/Kernel/ +Treiber/Partitionen sowie Reboot und Shutdown sind nicht Bestandteil des +Operators und dürfen nicht umgangen werden. Der offizielle GitHub-MCP `github/github-mcp-server` 1.10.1 läuft hinter einer reinen stdio-zu-Streamable-HTTP-Brücke. Aktiv sind ausschließlich: diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0daa80a..eeb8357 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -54,11 +54,14 @@ Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar. | Administration | Vorschau, Approval-Ticket, Verifikation | Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und -freie Dateisystemsuche gehören nicht ins Standardprofil. Der Athena Terminal -MCP ist davon ausdrücklich keine Ausnahme: Er sieht nur zwei read-only Mounts, -besitzt weder Docker-Socket noch Egress oder Host-PID-Namensraum und akzeptiert -nur eine kleine Positivliste direkter Lesebefehle. Reboot, Shutdown, SSH, -Interpreter, Service-Steuerung und sämtliche Schreibwege sind ausgeschlossen. +freie Dateisystemsuche gehören nicht ins Standardprofil. Für die Athena- +Plattform existiert genau ein Operator-MCP. Seine unprivilegierte Fassade sieht +nur einen lokalen Unix-Socket; ein rootseitiger Executor besitzt die für +Repository, Docker, Modelle, Git und Recovery notwendigen Rechte. Er bietet +keinen beliebigen Befehl an, sondern strukturierte Operationen mit Vorschau, +Inhaltsbindung, Ablaufzeit und späterer exakter Freigabe. SSH-, Netzwerk-, +WireGuard-, Firewall-, Boot-, Kernel-, Treiber-, Partitions-, Reboot- und +Shutdown-Änderungen liegen außerhalb seiner API. ## Schreibaktionen diff --git a/platform/checks/verify-platform.sh b/platform/checks/verify-platform.sh index 7407e1e..7339ead 100755 --- a/platform/checks/verify-platform.sh +++ b/platform/checks/verify-platform.sh @@ -64,7 +64,7 @@ else fi for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \ - mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-terminal \ + mike-ai-mcp-github mike-ai-mcp-platform-context mike-ai-mcp-athena-operator \ mike-ai-mcp-unraid-official; do if container_healthy "$optional"; then pass "$optional aktiv" diff --git a/platform/mcp/Dockerfile.athena-operator b/platform/mcp/Dockerfile.athena-operator new file mode 100644 index 0000000..921a9c5 --- /dev/null +++ b/platform/mcp/Dockerfile.athena-operator @@ -0,0 +1,13 @@ +FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a + +ARG MCP_PROXY_VERSION=0.12.0 +ARG MCP_VERSION=1.29.0 +RUN pip install --no-cache-dir "mcp==${MCP_VERSION}" "mcp-proxy==${MCP_PROXY_VERSION}" \ + && useradd --system --uid 10003 --create-home --home-dir /app operator +COPY athena_operator_mcp.py /app/athena_operator_mcp.py +RUN chown -R 10003:10003 /app +USER 10003:10003 +WORKDIR /app +EXPOSE 8000 +ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"] +CMD ["python", "/app/athena_operator_mcp.py"] diff --git a/platform/mcp/Dockerfile.athena-terminal b/platform/mcp/Dockerfile.athena-terminal deleted file mode 100644 index db85734..0000000 --- a/platform/mcp/Dockerfile.athena-terminal +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.13-slim@sha256:ffb752e139c0a19692a43af8d8523b274222dd68eebad5d583b45c2201c6e30a - -ARG MCP_PROXY_VERSION=0.12.0 -ARG MCP_VERSION=1.29.0 -ARG PYYAML_VERSION=6.0.3 -RUN apt-get update \ - && apt-get install -y --no-install-recommends bash file ripgrep \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir \ - "mcp==${MCP_VERSION}" "mcp-proxy==${MCP_PROXY_VERSION}" "PyYAML==${PYYAML_VERSION}" \ - && useradd --system --uid 10002 --create-home --home-dir /app terminal - -COPY athena_terminal_mcp.py /app/athena_terminal_mcp.py -RUN chown -R 10002:10002 /app - -USER 10002:10002 -WORKDIR /app -EXPOSE 8000 -ENTRYPOINT ["mcp-proxy", "--host", "0.0.0.0", "--port", "8000", "--stateless", "--"] -CMD ["python", "/app/athena_terminal_mcp.py"] diff --git a/platform/mcp/README.md b/platform/mcp/README.md index 0233751..3d42791 100644 --- a/platform/mcp/README.md +++ b/platform/mcp/README.md @@ -11,7 +11,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über | Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard | |---|---|---|---| | `mcp-platform-context` | `http://mike-ai-mcp-platform-context:8000/mcp` | Athena-Wissen, begrenzter Snapshot und kontrollierte Docs-Pflege | an | -| `mcp-athena-terminal` | `http://mike-ai-mcp-athena-terminal:8000/mcp` | fest begrenzte Lesebefehle und Syntaxprüfung für Athena-Quellen | an | +| `mcp-athena-operator` | `http://mike-ai-mcp-athena-operator:8000/mcp` | vollständiger Betrieb der Athena-KI-Plattform über Vorschau/Freigabe | an | | `mcp-web` | `http://mike-ai-mcp-web:8000/mcp` | kompakte Websuche und Quellenvergleich | an | | `mcp-homeassistant` | `http://mike-ai-mcp-homeassistant:8000/mcp` | Relay zum nativen HA-MCP; Token bleibt serverseitig | Profil `homeassistant` | | `mcp-arr` | `http://mike-ai-mcp-arr:8000/mcp` | Sonarr/Radarr/Prowlarr mit serverseitiger Policy | Profil `arr` | @@ -33,14 +33,16 @@ Host-Snapshot. Dokumentationspflege ist auf `docs/*.md` und einen zweistufigen Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung: [`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md). -Der Athena Terminal MCP ist ebenfalls **keine allgemeine Shell**. Er führt nur -eine kurze serverseitige Positivliste von Lesewerkzeugen mit direkten -Argumenten aus (`ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`, -`sha256sum`, `grep`, `rg`, `sed`, `du`, `df`). Sichtbar sind ausschließlich -der read-only eingebundene versionierte Stack und der begrenzte -Laufzeitsnapshot. Shell-Syntax, Interpreter, Netzwerkprogramme, SSH/SCP, -Docker, `systemctl`, Prozesssignale, Reboot und Shutdown sind nicht vorhanden -beziehungsweise werden bereits vor der Ausführung abgewiesen. +Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen +KI-Plattform. Qwen kann damit Quellen lesen, Änderungen vorbereiten, MCPs und +Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und +OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Die unprivilegierte +MCP-Fassade sieht dabei nur einen lokalen Unix-Socket. Docker-Socket, +Repository, Modellverzeichnis, Git-Zugang und Root-Rechte verbleiben im +rootseitigen Executor. Jede Änderung benötigt eine vollständige Vorschau, ein +inhaltlich gebundenes, ablaufendes Ticket und eine spätere exakte Bestätigung. +Eine freie Shell sowie SSH-, Netzwerk-, Boot-, Kernel-, Treiber-, Partitions-, +Reboot- und Shutdown-Aktionen werden nicht angeboten. TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis `/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen @@ -77,7 +79,7 @@ passenden Server wählen: | Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web | | Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR | | Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | Unraid (Systemdiagnose) | MUA | -| Athena-Quelldateien gezielt lesen oder Syntax prüfen | Athena Terminal | Platform Context für Architekturwissen | +| Athena-KI-Plattform entwickeln, testen, deployen, Modelle/Git/Recovery pflegen | Athena Operator | Platform Context für reine Architekturauskunft | | Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel | Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende @@ -95,7 +97,7 @@ oder ein anderes Werkzeug benötigt wird. `no-new-privileges`. - Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts. - Ein allgemeiner Host-Shell-MCP wird weiterhin bewusst nicht angeboten. Der - Athena Terminal MCP ist eine eng begrenzte Lesefassade, kein Host-Terminal. + Athena Operator besitzt strukturierte Plattformaktionen statt freier Befehle. ## Start diff --git a/platform/mcp/athena_operator_mcp.py b/platform/mcp/athena_operator_mcp.py new file mode 100644 index 0000000..cb00c12 --- /dev/null +++ b/platform/mcp/athena_operator_mcp.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Single MCP facade for end-to-end Athena platform operation.""" + +from __future__ import annotations + +import json +import os +import socket +import sys +from typing import Any + + +VERSION = "1.0.0" +SOCKET_PATH = os.environ.get("ATHENA_OPERATOR_SOCKET", "/operator/operator.sock") + +if hasattr(sys.stdin, "reconfigure"): + sys.stdin.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + + +TOOLS = [ + { + "name": "athena_operator_inspect", + "description": ( + "USE FIRST for Athena administration. Inspect live platform state without changing it. " + "Subjects: overview, git_status, containers, models, jobs. This is the authoritative " + "starting point before MCP, Docker, model, profile, benchmark or recovery work." + ), + "inputSchema": { + "type": "object", + "properties": {"subject": {"type": "string", "enum": ["overview", "git_status", "containers", "models", "jobs"], "default": "overview"}}, + "additionalProperties": False, + }, + }, + { + "name": "athena_operator_read_source", + "description": ( + "Read a versioned Athena repository file with SHA-256 and bounded line range. Use this " + "instead of guessing current Compose, MCP, router, model, OpenWebUI or recovery code." + ), + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "pattern": "^[A-Za-z0-9_.+/-]{1,240}$"}, + "start_line": {"type": "integer", "minimum": 1, "maximum": 1000000, "default": 1}, + "line_count": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 300}, + }, + "required": ["path"], "additionalProperties": False, + }, + }, + { + "name": "athena_operator_search_source", + "description": "Search the complete versioned Athena repository for exact text before designing or modifying a component.", + "inputSchema": {"type": "object", "properties": {"query": {"type": "string", "minLength": 1, "maxLength": 200}}, "required": ["query"], "additionalProperties": False}, + }, + { + "name": "athena_operator_prepare", + "description": ( + "PREPARE a state-changing Athena operation. This never changes state. It returns a " + "content-bound ticket, exact preview and confirmation phrase. Supported operations: " + "file_update (write repository and deployed stack files), run_checks, compose_deploy, " + "container_action, openwebui_sync, git_publish, model_download, benchmark, recovery. Show the complete " + "preview to the user and stop. Never execute in the same autonomous tool sequence. " + "file_update payload: {files:[{path,content,expected_sha256?}]}; run_checks: " + "{checks:[operator-tests,openwebui-filter-tests,platform-verify,compose-main,compose-mcp]}; " + "compose_deploy: {compose_file,services,build}; container_action: {action,containers}; " + "openwebui_sync: {}; " + "git_publish: {message}; model_download: {url,destination,sha256?}; benchmark: " + "{script,arguments}; recovery: {label}." + ), + "inputSchema": { + "type": "object", + "properties": { + "operation": {"type": "string", "enum": ["file_update", "run_checks", "compose_deploy", "container_action", "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery"]}, + "payload": {"type": "object"}, + }, + "required": ["operation", "payload"], "additionalProperties": False, + }, + }, + { + "name": "athena_operator_execute", + "description": ( + "EXECUTE exactly one previously prepared Athena operation. Call only after the user " + "approved the exact preview in a later message and supplied the exact confirmation. " + "Tickets expire, are content-bound and single-use. Long downloads, benchmarks and " + "recovery work return a job id. This tool cannot alter SSH, networking, WireGuard, " + "firewall, boot, kernel, drivers, partitions, mounts, shutdown or reboot." + ), + "inputSchema": { + "type": "object", + "properties": { + "ticket": {"type": "string", "pattern": "^[0-9a-f]{32}$"}, + "confirmation": {"type": "string", "pattern": "^EXECUTE [0-9a-f]{32}$"}, + }, + "required": ["ticket", "confirmation"], "additionalProperties": False, + }, + }, + { + "name": "athena_operator_job", + "description": "Poll one asynchronous model download, benchmark or recovery job. Do not start duplicate jobs while it is running.", + "inputSchema": {"type": "object", "properties": {"job_id": {"type": "string", "pattern": "^[0-9a-f]{32}$"}}, "required": ["job_id"], "additionalProperties": False}, + }, +] + + +def request(action: str, arguments: dict[str, Any]) -> dict[str, Any]: + payload = json.dumps({"action": action, "arguments": arguments}, ensure_ascii=False, separators=(",", ":")).encode() + b"\n" + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(20) + client.connect(SOCKET_PATH) + client.sendall(payload) + chunks = bytearray() + while not chunks.endswith(b"\n"): + part = client.recv(65536) + if not part: + break + chunks.extend(part) + if len(chunks) > 2_000_000: + raise RuntimeError("operator response exceeds limit") + response = json.loads(chunks) + if not response.get("ok"): + raise RuntimeError(response.get("error", "operator request failed")) + return response["result"] + + +def call(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + mapping = { + "athena_operator_inspect": "inspect", + "athena_operator_read_source": "read_source", + "athena_operator_search_source": "search_source", + "athena_operator_prepare": "prepare", + "athena_operator_execute": "execute", + "athena_operator_job": "job", + } + if name not in mapping: + raise ValueError("unknown tool") + return request(mapping[name], arguments) + + +def emit(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None: + message = {"jsonrpc": "2.0", "id": request_id} + message["error" if error else "result"] = error or result + sys.stdout.write(json.dumps(message, ensure_ascii=False, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def handle(message: dict[str, Any]) -> None: + method, request_id = message.get("method"), message.get("id") + if method == "initialize": + emit(request_id, {"protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "mike-ai-athena-operator", "version": VERSION}}) + elif method == "tools/list": + emit(request_id, {"tools": TOOLS}) + elif method == "tools/call": + params = message.get("params", {}) + try: + value = call(str(params.get("name", "")), params.get("arguments") or {}) + emit(request_id, {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False, separators=(",", ":"))}], "structuredContent": value, "isError": False}) + except Exception as exc: + emit(request_id, {"content": [{"type": "text", "text": f"ERROR: {exc}"}], "isError": True}) + elif request_id is not None: + emit(request_id, error={"code": -32601, "message": "method not found"}) + + +def main() -> None: + for line in sys.stdin: + try: + if line.strip(): handle(json.loads(line)) + except Exception as exc: + sys.stderr.write(f"MCP input error: {exc}\n"); sys.stderr.flush() + + +if __name__ == "__main__": + main() diff --git a/platform/mcp/athena_terminal_mcp.py b/platform/mcp/athena_terminal_mcp.py deleted file mode 100644 index e91bf7d..0000000 --- a/platform/mcp/athena_terminal_mcp.py +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env python3 -"""Capability-bounded terminal MCP for the Athena source tree. - -This is deliberately not a general shell. Commands are executed without a -shell, against read-only mounts, with a fixed environment and a strict program -and argument allowlist. The container has no Docker socket, host PID namespace, -SSH material, secrets or egress network. -""" - -from __future__ import annotations - -import ast -import json -import os -import re -import subprocess -import sys -from pathlib import Path -from typing import Any - - -SERVER_VERSION = "1.0.0" -WORKSPACE_ROOT = Path(os.environ.get("ATHENA_TERMINAL_WORKSPACE", "/workspace")).resolve() -RUNTIME_ROOT = Path(os.environ.get("ATHENA_TERMINAL_RUNTIME", "/runtime")).resolve() -MAX_OUTPUT_CHARS = 20_000 -MAX_ARGUMENTS = 32 -COMMAND_TIMEOUT_SECONDS = 8 -ALLOWED_ROOTS = (WORKSPACE_ROOT, RUNTIME_ROOT) -BLOCKED_PROGRAMS = { - "ash", "bash", "busybox", "chroot", "curl", "dash", "docker", "env", - "fish", "ftp", "halt", "init", "kill", "killall", "ksh", "mount", - "nc", "netcat", "nft", "nohup", "perl", "php", "pkill", "poweroff", - "python", "python3", "reboot", "rsync", "scp", "sh", "shutdown", "ssh", - "sftp", "socat", "sudo", "su", "systemctl", "telnet", "umount", "wget", - "zsh", -} -ALLOWED_PROGRAMS = { - "cat", "df", "du", "file", "grep", "head", "ls", "rg", "sed", - "sha256sum", "stat", "tail", "wc", -} -SENSITIVE_PATH_TOKENS = { - ".env", "authorized_keys", "agekey", "credentials", "id_ed25519", - "id_rsa", "private_key", "secret", "secrets", "shadow", -} - -if hasattr(sys.stdin, "reconfigure"): - sys.stdin.reconfigure(encoding="utf-8", errors="replace") -if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(encoding="utf-8", errors="replace") - - -TOOLS = [ - { - "name": "athena_terminal_policy", - "description": ( - "USE FIRST before terminal work on Athena. Returns the exact capability boundary, " - "allowed read-only programs, visible roots and explicitly unavailable operations. " - "This tool performs no command. The terminal is not a shell and cannot access SSH, " - "Docker control, host services, secrets, networking, shutdown or reboot." - ), - "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, - }, - { - "name": "athena_terminal_run", - "description": ( - "Run one bounded read-only command against Athena's versioned stack or bounded " - "runtime snapshot. Pass a program and an argument array; shell syntax, pipelines, " - "redirection and command substitution do not exist. Allowed programs are ls, cat, " - "head, tail, wc, stat, file, sha256sum, grep, rg, sed, du and df. Paths may only be " - "inside /workspace or /runtime. Never claim this can deploy, edit, control Docker, " - "restart services, use SSH or alter the host." - ), - "inputSchema": { - "type": "object", - "properties": { - "program": {"type": "string", "enum": sorted(ALLOWED_PROGRAMS)}, - "arguments": { - "type": "array", - "maxItems": MAX_ARGUMENTS, - "items": {"type": "string", "maxLength": 500}, - "default": [], - }, - "working_directory": { - "type": "string", - "enum": ["workspace", "runtime"], - "default": "workspace", - }, - }, - "required": ["program"], - "additionalProperties": False, - }, - }, - { - "name": "athena_terminal_validate_source", - "description": ( - "Validate exactly one versioned source file without executing it. Supports Python " - "AST parsing, Bash syntax-only parsing, JSON parsing and YAML parsing. The file must " - "be below /workspace and cannot be a secret-bearing path. This does not build, run, " - "deploy or modify anything." - ), - "inputSchema": { - "type": "object", - "properties": { - "path": { - "type": "string", - "minLength": 1, - "maxLength": 240, - "pattern": "^[A-Za-z0-9_./-]+$", - }, - "kind": { - "type": "string", - "enum": ["auto", "python", "shell", "json", "yaml"], - "default": "auto", - }, - }, - "required": ["path"], - "additionalProperties": False, - }, - }, -] - - -def json_text(value: Any) -> str: - return json.dumps(value, ensure_ascii=False, separators=(",", ":")) - - -def within_root(path: Path, root: Path) -> bool: - try: - path.relative_to(root) - return True - except ValueError: - return False - - -def reject_sensitive_path(path: Path) -> None: - lowered_parts = {part.casefold() for part in path.parts} - lowered_name = path.name.casefold() - if lowered_parts & SENSITIVE_PATH_TOKENS: - raise PermissionError("secret-bearing paths are not accessible") - if any(token in lowered_name for token in ("secret", "credential", "agekey", "private")): - raise PermissionError("secret-bearing paths are not accessible") - - -def safe_path(value: str, cwd: Path, *, must_exist: bool = True) -> Path: - if not value or "\x00" in value or "\n" in value or "\r" in value: - raise ValueError("invalid path") - candidate = Path(value) - candidate = candidate if candidate.is_absolute() else cwd / candidate - resolved = candidate.resolve(strict=False) - if not any(within_root(resolved, root) for root in ALLOWED_ROOTS): - raise PermissionError("path is outside the allowed terminal roots") - reject_sensitive_path(resolved) - if must_exist and not resolved.exists(): - raise FileNotFoundError("path does not exist") - return resolved - - -def clean_scalar(value: str) -> str: - if not isinstance(value, str) or len(value) > 500: - raise ValueError("invalid argument") - if any(char in value for char in ("\x00", "\n", "\r")): - raise ValueError("multiline and NUL arguments are forbidden") - if any(token in value for token in ("$(", "${", "`", ">", "<", "|", ";", "&&", "||")): - raise ValueError("shell syntax is forbidden") - return value - - -def path_argument(value: str, cwd: Path) -> str: - if value == "-": - raise ValueError("stdin paths are not supported") - return str(safe_path(value, cwd)) - - -def validate_arguments(program: str, arguments: list[str], cwd: Path) -> list[str]: - if program in BLOCKED_PROGRAMS or program not in ALLOWED_PROGRAMS: - raise PermissionError("program is not allowed") - if len(arguments) > MAX_ARGUMENTS: - raise ValueError("too many arguments") - args = [clean_scalar(value) for value in arguments] - - if program == "cat": - if not args: - raise ValueError("cat requires at least one file") - return [path_argument(value, cwd) for value in args] - - if program in {"sha256sum", "file"}: - allowed_flags = {"-b"} if program == "file" else set() - result = [] - for value in args: - if value.startswith("-"): - if value not in allowed_flags: - raise ValueError("unsupported option") - result.append(value) - else: - result.append(path_argument(value, cwd)) - if not any(not value.startswith("-") for value in args): - raise ValueError(f"{program} requires a path") - return result - - if program in {"head", "tail"}: - result = [] - index = 0 - if len(args) >= 2 and args[0] == "-n": - if not re.fullmatch(r"[0-9]{1,5}", args[1]) or int(args[1]) > 2000: - raise ValueError("line count must be between 0 and 2000") - result.extend(args[:2]) - index = 2 - paths = args[index:] - if not paths: - raise ValueError(f"{program} requires a path") - result.extend(path_argument(value, cwd) for value in paths) - return result - - if program == "wc": - allowed = {"-c", "-l", "-m", "-w"} - result = [] - paths = 0 - for value in args: - if value.startswith("-"): - if value not in allowed: - raise ValueError("unsupported wc option") - result.append(value) - else: - result.append(path_argument(value, cwd)) - paths += 1 - if paths == 0: - raise ValueError("wc requires a path") - return result - - if program == "stat": - if not args: - raise ValueError("stat requires a path") - if any(value.startswith("-") for value in args): - raise ValueError("stat options are not supported") - return [path_argument(value, cwd) for value in args] - - if program == "ls": - allowed = {"-1", "-a", "-al", "-d", "-h", "-l", "-la", "-lh", "-R"} - result = [] - for value in args: - if value.startswith("-"): - if value not in allowed: - raise ValueError("unsupported ls option") - result.append(value) - else: - result.append(path_argument(value, cwd)) - if not any(not value.startswith("-") for value in args): - result.append(str(cwd)) - return result - - if program == "sed": - if len(args) < 3 or args[0] != "-n": - raise ValueError("sed only supports: -n START[,END]p FILE...") - if not re.fullmatch(r"[0-9]{1,7}(,[0-9]{1,7})?p", args[1]): - raise ValueError("sed expression is limited to printing a line range") - return ["-n", args[1], *[path_argument(value, cwd) for value in args[2:]]] - - if program == "grep": - # GNU grep -R follows symlinks. Only -r is permitted because it skips - # directory symlinks and therefore preserves the visible-root boundary. - allowed = {"-c", "-E", "-F", "-i", "-l", "-n", "-r"} - result = [] - index = 0 - while index < len(args) and args[index].startswith("-"): - if args[index] not in allowed: - raise ValueError("unsupported grep option") - result.append(args[index]) - index += 1 - if index >= len(args): - raise ValueError("grep requires a pattern") - result.append(args[index]) - index += 1 - paths = args[index:] or [str(cwd)] - result.extend(path_argument(value, cwd) for value in paths) - return result - - if program == "rg": - allowed_flags = {"--files", "--hidden", "--json", "-c", "-F", "-i", "-l", "-n"} - result = [] - index = 0 - files_mode = False - while index < len(args) and args[index].startswith("-"): - value = args[index] - if value in {"-g", "--glob"}: - if index + 1 >= len(args): - raise ValueError("missing glob value") - result.extend([value, args[index + 1]]) - index += 2 - continue - if value not in allowed_flags: - raise ValueError("unsupported rg option") - files_mode = files_mode or value == "--files" - result.append(value) - index += 1 - if not files_mode: - if index >= len(args): - raise ValueError("rg requires a pattern") - result.append(args[index]) - index += 1 - paths = args[index:] or [str(cwd)] - result.extend(path_argument(value, cwd) for value in paths) - return result - - if program == "du": - allowed = {"-a", "-h", "-s", "-sh"} - result = [] - for value in args: - if value.startswith("--max-depth="): - depth = value.split("=", 1)[1] - if not depth.isdigit() or int(depth) > 5: - raise ValueError("du max depth must be between 0 and 5") - result.append(value) - elif value.startswith("-"): - if value not in allowed: - raise ValueError("unsupported du option") - result.append(value) - else: - result.append(path_argument(value, cwd)) - if not any(not value.startswith("-") for value in args): - result.append(str(cwd)) - return result - - if program == "df": - allowed = {"-h", "-T", "-hT", "-Th"} - result = [] - for value in args: - if value.startswith("-"): - if value not in allowed: - raise ValueError("unsupported df option") - result.append(value) - else: - result.append(path_argument(value, cwd)) - return result - - raise PermissionError("program policy is incomplete") - - -def compact_output(text: str) -> tuple[str, bool]: - if len(text) <= MAX_OUTPUT_CHARS: - return text, False - marker = f"\n...[output truncated from {len(text)} characters]...\n" - remaining = MAX_OUTPUT_CHARS - len(marker) - return text[: int(remaining * 0.75)] + marker + text[-int(remaining * 0.25) :], True - - -def policy() -> dict[str, Any]: - return { - "mode": "bounded-read-only-terminal", - "allowed_programs": sorted(ALLOWED_PROGRAMS), - "visible_roots": [str(root) for root in ALLOWED_ROOTS], - "execution": "direct argv only; no shell, pipelines, redirection or substitution", - "limits": { - "timeout_seconds": COMMAND_TIMEOUT_SECONDS, - "max_arguments": MAX_ARGUMENTS, - "max_output_characters": MAX_OUTPUT_CHARS, - }, - "unavailable": [ - "SSH/SCP/SFTP and all remote login", - "shutdown, reboot, halt and power operations", - "Docker socket, Docker control and container exec", - "systemctl, service control, process signals and host PID namespace", - "network clients, internet access, VPN/firewall/routing changes", - "interpreters, arbitrary scripts and package installation", - "writes to the Athena stack, host filesystem, Git or secrets", - ], - "instruction": ( - "This terminal supplies evidence and syntax validation only. Use a separate, " - "ticket-bound operator workflow for future deployments or state changes." - ), - } - - -def run_command(arguments: dict[str, Any]) -> dict[str, Any]: - program = str(arguments.get("program", "")) - raw_args = arguments.get("arguments") or [] - if not isinstance(raw_args, list) or not all(isinstance(value, str) for value in raw_args): - raise ValueError("arguments must be a string array") - cwd_name = str(arguments.get("working_directory", "workspace")) - cwd = WORKSPACE_ROOT if cwd_name == "workspace" else RUNTIME_ROOT if cwd_name == "runtime" else None - if cwd is None or not cwd.is_dir(): - raise ValueError("working directory is unavailable") - argv = [program, *validate_arguments(program, raw_args, cwd)] - environment = { - "HOME": "/nonexistent", - "LANG": "C.UTF-8", - "LC_ALL": "C.UTF-8", - "PATH": "/usr/local/bin:/usr/bin:/bin", - "PAGER": "cat", - "RIPGREP_CONFIG_PATH": "/nonexistent", - } - try: - completed = subprocess.run( - argv, - cwd=cwd, - env=environment, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - errors="replace", - timeout=COMMAND_TIMEOUT_SECONDS, - check=False, - ) - except subprocess.TimeoutExpired as exc: - partial = (exc.stdout or "") + (exc.stderr or "") - output, truncated = compact_output(str(partial)) - return { - "program": program, - "exit_code": None, - "timed_out": True, - "truncated": truncated, - "output": output, - "instruction": "The process was killed at the fixed timeout; do not retry in a loop.", - } - output, truncated = compact_output(completed.stdout + completed.stderr) - return { - "program": program, - "exit_code": completed.returncode, - "timed_out": False, - "truncated": truncated, - "output": output, - "read_only": True, - } - - -def validate_source(arguments: dict[str, Any]) -> dict[str, Any]: - relative = str(arguments.get("path", "")) - target = safe_path(relative, WORKSPACE_ROOT) - if not within_root(target, WORKSPACE_ROOT) or not target.is_file(): - raise PermissionError("validation is limited to files below /workspace") - kind = str(arguments.get("kind", "auto")) - suffix = target.suffix.casefold() - if kind == "auto": - if suffix == ".py": - kind = "python" - elif suffix in {".sh", ".bash"}: - kind = "shell" - elif suffix == ".json": - kind = "json" - elif suffix in {".yaml", ".yml"}: - kind = "yaml" - else: - raise ValueError("cannot infer validation kind for this file") - text = target.read_text(encoding="utf-8", errors="strict") - if len(text) > 2_000_000: - raise ValueError("source file exceeds validation limit") - if kind == "python": - ast.parse(text, filename=str(target)) - elif kind == "json": - json.loads(text) - elif kind == "yaml": - import yaml - - yaml.safe_load(text) - elif kind == "shell": - completed = subprocess.run( - ["/bin/bash", "-n", str(target)], - env={"PATH": "/usr/bin:/bin", "LANG": "C.UTF-8"}, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=COMMAND_TIMEOUT_SECONDS, - check=False, - ) - if completed.returncode != 0: - raise ValueError(compact_output(completed.stderr)[0]) - else: - raise ValueError("unsupported validation kind") - return { - "path": str(target.relative_to(WORKSPACE_ROOT)), - "kind": kind, - "valid": True, - "executed": False, - "modified": False, - } - - -def call_tool(name: str, arguments: dict[str, Any]) -> str: - if name == "athena_terminal_policy": - result = policy() - elif name == "athena_terminal_run": - result = run_command(arguments) - elif name == "athena_terminal_validate_source": - result = validate_source(arguments) - else: - raise ValueError(f"unknown tool: {name}") - return json_text(result) - - -def response(request_id: Any, result: Any = None, error: dict[str, Any] | None = None) -> None: - payload: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id} - payload["error" if error is not None else "result"] = error if error is not None else result - sys.stdout.write(json_text(payload) + "\n") - sys.stdout.flush() - - -def handle(message: dict[str, Any]) -> None: - method = message.get("method") - request_id = message.get("id") - if method == "initialize": - response( - request_id, - { - "protocolVersion": message.get("params", {}).get("protocolVersion", "2024-11-05"), - "capabilities": {"tools": {"listChanged": False}}, - "serverInfo": {"name": "mike-ai-athena-terminal", "version": SERVER_VERSION}, - }, - ) - elif method == "tools/list": - response(request_id, {"tools": TOOLS}) - elif method == "tools/call": - params = message.get("params", {}) - try: - text = call_tool(str(params.get("name", "")), params.get("arguments") or {}) - response( - request_id, - { - "content": [{"type": "text", "text": text}], - "structuredContent": json.loads(text), - "isError": False, - }, - ) - except Exception as exc: - response( - request_id, - { - "content": [{"type": "text", "text": f"ERROR: {exc}"}], - "isError": True, - }, - ) - elif request_id is not None: - response(request_id, error={"code": -32601, "message": f"Method not found: {method}"}) - - -def main() -> None: - for line in sys.stdin: - try: - if line.strip(): - handle(json.loads(line)) - except Exception as exc: - sys.stderr.write(f"MCP input error: {exc}\n") - sys.stderr.flush() - - -if __name__ == "__main__": - main() diff --git a/platform/mcp/compose.yaml b/platform/mcp/compose.yaml index 3bef50c..1de0365 100644 --- a/platform/mcp/compose.yaml +++ b/platform/mcp/compose.yaml @@ -166,22 +166,20 @@ services: retries: 5 start_period: 10s - mcp-athena-terminal: + mcp-athena-operator: <<: *tool-common build: context: . - dockerfile: Dockerfile.athena-terminal - image: mike-ai/mcp-athena-terminal:1.0.0 - container_name: mike-ai-mcp-athena-terminal + dockerfile: Dockerfile.athena-operator + image: mike-ai/mcp-athena-operator:1.0.0 + container_name: mike-ai-mcp-athena-operator environment: - ATHENA_TERMINAL_WORKSPACE: /workspace - ATHENA_TERMINAL_RUNTIME: /runtime + ATHENA_OPERATOR_SOCKET: /operator/operator.sock volumes: - # The terminal sees only versioned source and the bounded, payload-free - # runtime snapshot. It receives no Docker socket, host filesystem, - # secrets, SSH material, devices, PID namespace or egress network. - - ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/workspace:ro - - ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro + # The unprivileged MCP facade sees only the root-owned executor socket. + # Docker, source, models, Git credentials and host paths remain on the + # executor side and are reachable only through structured operations. + - /run/mike-ai-operator:/operator:ro networks: [tools] healthcheck: test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"] diff --git a/platform/mcp/install-tools.sh b/platform/mcp/install-tools.sh index 035fdcc..e88fbaf 100755 --- a/platform/mcp/install-tools.sh +++ b/platform/mcp/install-tools.sh @@ -34,6 +34,11 @@ systemctl daemon-reload systemctl enable --now mike-ai-platform-context-snapshot.timer systemctl start mike-ai-platform-context-snapshot.service +# One user-facing Athena Operator MCP controls the complete local AI platform +# through a root-side structured executor. It is intentionally not a general +# shell and exposes no raw Docker socket or host paths to the MCP container. +"$MCP_DIR/../operator/install-operator.sh" + profiles=() if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then profiles+=(--profile homeassistant) diff --git a/platform/openwebui/filters/auto_tool_selector.py b/platform/openwebui/filters/auto_tool_selector.py index 61b13dc..7591269 100644 --- a/platform/openwebui/filters/auto_tool_selector.py +++ b/platform/openwebui/filters/auto_tool_selector.py @@ -1,7 +1,7 @@ """ title: MikeAI Auto Tool Selector author: MikeAI -version: 1.1.0 +version: 2.0.0 description: Selects a small, relevant set of MCP servers for each user request. """ @@ -27,7 +27,7 @@ class Filter: "unraid": "server:mcp:unraid-readonly-local", "navidrome": "server:mcp:navidrome-local", "platform": "server:mcp:athena-platform", - "terminal": "server:mcp:athena-terminal-local", + "operator": "server:mcp:athena-operator-local", } LABELS = { @@ -38,7 +38,7 @@ class Filter: "unraid": "Unraid-Diagnose", "navidrome": "Navidrome", "platform": "Athena-Plattformwissen", - "terminal": "Athena-Terminal (begrenzt, nur lesend)", + "operator": "Athena Operator", } def __init__(self): @@ -96,13 +96,14 @@ class Filter: selected: list[str] = [] - terminal = self._matches( + operator = self._matches( text, ( - r"\bathena[- ]terminal\b", - r"\b(?:athena|ki[- ]host)\b.*\b(?:terminal|shell|kommando|befehl)\b", - r"\b(?:terminal|shell)\b.*\b(?:athena|ki[- ]host)\b", - r"\b(?:ls|tail|grep|rg|cat)\b.*\b(?:athena|ki[- ]host)\b", + r"\bathena[- ]operator\b", + r"\b(?:athena|ki[- ]host)\b.*\b(?:bau|[aä]nder|deploy|install|lad|test|start|stop|restart|konfigurier|aktualisier|entfern|erstell|implementier|entwickel)\w*\b", + r"\b(?:bau|[aä]nder|deploy|install|lad|test|start|stop|restart|konfigurier|aktualisier|entfern|erstell|implementier|entwickel)\w*\b.*\b(?:athena|ki[- ]host)\b", + r"\bmcp\b.*\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b", + r"\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b.*\bmcp\b", ), ) platform = self._matches( @@ -114,8 +115,6 @@ class Filter: r"\b(?:disaster|bare metal)[- ]recovery\b", r"\b(?:installations?|reinstall|setup)[- ]skript\b", r"\bplattform(?:wissen|dokumentation)?\b", - r"\bmcp\b.*\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b", - r"\b(?:bau\w*|implementier\w*|entwickel\w*|integrier\w*)\b.*\bmcp\b", ), ) homeassistant = self._matches( @@ -168,8 +167,8 @@ class Filter: # A specialist source is more precise than public web search. Platform # wins over a generic Docker mention when Athena is explicitly named. - if terminal: - selected.append("terminal") + if operator: + selected.append("operator") elif platform: selected.append("platform") elif homeassistant: diff --git a/platform/openwebui/install-filters.sh b/platform/openwebui/install-filters.sh index c7ae652..cd8cf0a 100755 --- a/platform/openwebui/install-filters.sh +++ b/platform/openwebui/install-filters.sh @@ -165,6 +165,18 @@ with con: connections = json.loads(row[0]) if not isinstance(connections, list): raise SystemExit("Unbekanntes Format in tool_server.connections.") + before_count = len(connections) + connections = [ + connection for connection in connections + if not ( + isinstance(connection, dict) + and ( + str((connection.get("info") or {}).get("id", "")).lower() + == "athena-terminal-local" + or "mike-ai-mcp-athena-terminal" in str(connection.get("url", "")).lower() + ) + ) + ] descriptions = { "web-local": ( "Web (öffentlich, read-only)", @@ -212,15 +224,16 @@ with con: "Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, " "Pull Requests, Actions oder Schreibzugriffe.", ), - "athena-terminal-local": ( - "Athena Terminal (begrenzt, nur lesend)", - "Nur für gezielte Beweissuche in Athenas versioniertem Stack und einem " - "begrenzten Laufzeitsnapshot. Unterstützt ausschließlich fest erlaubte " - "Lesebefehle und Syntaxprüfung. Kein SSH, Docker, Netzwerk, Schreibzugriff, " - "Reboot, Shutdown, systemctl, Interpreter oder Zugriff auf Secrets.", + "athena-operator-local": ( + "Athena Operator", + "Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, " + "Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, " + "prüfen, dokumentieren, versionieren und Recovery erzeugen. Änderungen benötigen " + "eine inhaltlich gebundene Vorschau und ausdrückliche Bestätigung. Kein freies " + "Terminal und keine SSH-, Netzwerk-, Boot-, Reboot- oder Shutdown-Änderungen.", ), } - changed = False + changed = len(connections) != before_count for connection in connections: if not isinstance(connection, dict): continue @@ -244,8 +257,8 @@ with con: match = "navidrome-local" elif "mike-ai-mcp-github" in url: match = "github-local" - elif "mike-ai-mcp-athena-terminal" in url: - match = "athena-terminal-local" + elif "mike-ai-mcp-athena-operator" in url: + match = "athena-operator-local" elif "mike-ai-mcp-unraid-official" in url: match = "unraid-readonly-local" else: @@ -287,16 +300,16 @@ with con: isinstance(connection, dict) and ( str(connection.get("url", "")).lower() - == "http://mike-ai-mcp-athena-terminal:8000/mcp" + == "http://mike-ai-mcp-athena-operator:8000/mcp" or str((connection.get("info") or {}).get("id", "")).lower() - == "athena-terminal-local" + == "athena-operator-local" ) for connection in connections ): - name, description = descriptions["athena-terminal-local"] + name, description = descriptions["athena-operator-local"] connections.append( { - "url": "http://mike-ai-mcp-athena-terminal:8000/mcp", + "url": "http://mike-ai-mcp-athena-operator:8000/mcp", "path": "", "type": "mcp", "auth_type": "none", @@ -304,7 +317,7 @@ with con: "key": "", "config": {"enable": True, "access_grants": []}, "info": { - "id": "athena-terminal-local", + "id": "athena-operator-local", "name": name, "description": description, }, diff --git a/platform/operator/athena-operator.service b/platform/operator/athena-operator.service new file mode 100644 index 0000000..c7e7abc --- /dev/null +++ b/platform/operator/athena-operator.service @@ -0,0 +1,20 @@ +[Unit] +Description=MikeAI Athena platform operator executor +After=docker.service data.mount network-online.target +Wants=docker.service network-online.target + +[Service] +Type=simple +EnvironmentFile=-/etc/mike-ai/athena-operator.env +ExecStart=/usr/local/libexec/mike-ai-athena-operatord +Restart=on-failure +RestartSec=3 +UMask=0077 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=read-only +ProtectSystem=full +ReadWritePaths=/opt/mike-ai/stack /data/mike-ai-operator /data/models /run/mike-ai-operator /data + +[Install] +WantedBy=multi-user.target diff --git a/platform/operator/athena_operatord.py b/platform/operator/athena_operatord.py new file mode 100644 index 0000000..2ddcbc5 --- /dev/null +++ b/platform/operator/athena_operatord.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +"""Root-side executor for the single Athena Operator MCP. + +The daemon exposes structured platform operations over a local Unix socket. +It deliberately has no arbitrary-command endpoint. Every mutation is first +materialised as an expiring, content-bound proposal and requires its exact +confirmation string in a later call. +""" + +from __future__ import annotations + +import difflib +import hashlib +import json +import os +import re +import shutil +import socketserver +import subprocess +import tempfile +import threading +import time +import urllib.parse +import urllib.request +import uuid +from pathlib import Path +from typing import Any + + +VERSION = "1.0.0" +STACK = Path(os.environ.get("ATHENA_OPERATOR_STACK", "/opt/mike-ai/stack")).resolve() +REPOSITORY = Path(os.environ.get("ATHENA_OPERATOR_REPOSITORY", "/data/mike-ai-operator/repository")).resolve() +STATE = Path(os.environ.get("ATHENA_OPERATOR_STATE", "/data/mike-ai-operator/state")).resolve() +SOCKET = Path(os.environ.get("ATHENA_OPERATOR_SOCKET", "/run/mike-ai-operator/operator.sock")) +MODELS = Path(os.environ.get("ATHENA_OPERATOR_MODELS", "/data/models")).resolve() +TICKET_TTL = 1800 +MAX_FILE_BYTES = 1_000_000 +MAX_FILES = 24 +MAX_OUTPUT = 30_000 +LOCK = threading.RLock() + +SAFE_PATH = re.compile(r"^[A-Za-z0-9_.+/-]{1,240}$") +SAFE_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,100}$") +SAFE_COMMIT = re.compile(r"^[A-Za-z0-9ÄÖÜäöüß _.,:;()+/\-]{5,120}$") +BLOCKED_PATH_PARTS = {".git", ".ssh", "secrets", "credentials", "authorized_keys"} +PROTECTED_CONTAINERS = { + "mike-ai-wireguard-gateway", +} +ALLOWED_OPERATIONS = { + "file_update", "run_checks", "compose_deploy", "container_action", + "openwebui_sync", "git_publish", "model_download", "benchmark", "recovery", +} +ALLOWED_CHECKS = { + "operator-tests": ["python3", "dev/test_athena_operator.py"], + "openwebui-filter-tests": ["python3", "dev/test_openwebui_filters.py"], + "platform-verify": ["bash", "platform/checks/verify-platform.sh"], + "compose-main": ["docker", "compose", "-f", "compose.yaml", "config", "-q"], + "compose-mcp": ["docker", "compose", "-f", "platform/mcp/compose.yaml", "config", "-q"], +} + + +def now() -> int: + return int(time.time()) + + +def compact(text: str) -> str: + if len(text) <= MAX_OUTPUT: + return text + return text[:22_000] + f"\n...[truncated from {len(text)} chars]...\n" + text[-7_000:] + + +def run(argv: list[str], *, cwd: Path = STACK, timeout: int = 900, check: bool = False) -> dict[str, Any]: + completed = subprocess.run( + argv, cwd=cwd, env={"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "HOME": "/root"}, + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors="replace", timeout=timeout, check=False, + ) + result = {"argv": argv, "exit_code": completed.returncode, "output": compact(completed.stdout)} + if check and completed.returncode != 0: + raise RuntimeError(json.dumps(result, ensure_ascii=False)) + return result + + +def safe_relative(value: str) -> Path: + if not SAFE_PATH.fullmatch(value or "") or value.startswith("/"): + raise ValueError("invalid repository-relative path") + path = Path(value) + if ".." in path.parts or any(part.casefold() in BLOCKED_PATH_PARTS for part in path.parts): + raise PermissionError("protected path") + resolved = (REPOSITORY / path).resolve(strict=False) + resolved.relative_to(REPOSITORY) + return path + + +def source_file(root: Path, relative: Path) -> Path: + candidate = (root / relative).resolve(strict=False) + candidate.relative_to(root) + return candidate + + +def sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def json_write(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8") + os.replace(temporary, path) + + +def audit(event: str, **fields: Any) -> None: + record = {"time": now(), "event": event, **fields} + path = STATE / "audit.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + + +def ensure_repository() -> None: + if not (REPOSITORY / ".git").is_dir(): + bundle = Path("/data/mike-ai-recovery-kit/source.git.bundle") + if not bundle.is_file(): + raise RuntimeError("operator repository missing and no recovery Git bundle is available") + REPOSITORY.parent.mkdir(parents=True, exist_ok=True) + run(["git", "clone", str(bundle), str(REPOSITORY)], cwd=Path("/data"), check=True) + current_file = STACK / ".mike-ai-source-commit" + current = current_file.read_text().strip() if current_file.is_file() else "" + if re.fullmatch(r"[0-9a-f]{40}", current): + run(["git", "switch", "-C", "main", current], cwd=REPOSITORY, check=True) + remote = os.environ.get("ATHENA_OPERATOR_GIT_REMOTE", "").strip() + if remote: + run(["git", "remote", "set-url", "origin", remote], cwd=REPOSITORY, check=True) + run(["git", "config", "user.name", os.environ.get("ATHENA_OPERATOR_GIT_NAME", "Athena Operator")], cwd=REPOSITORY, check=True) + run(["git", "config", "user.email", os.environ.get("ATHENA_OPERATOR_GIT_EMAIL", "athena-operator@localhost")], cwd=REPOSITORY, check=True) + + +def inspect(subject: str, arguments: dict[str, Any]) -> dict[str, Any]: + ensure_repository() + if subject == "overview": + return { + "version": VERSION, + "source_commit": (STACK / ".mike-ai-source-commit").read_text().strip(), + "git": run(["git", "status", "--short", "--branch"], cwd=REPOSITORY), + "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"]), + "storage": run(["df", "-h", "/", "/data", str(MODELS)]), + "gpus": run(["nvidia-smi", "--query-gpu=name,memory.total,memory.used,utilization.gpu", "--format=csv,noheader"]), + "boundary": "Athena AI-platform operator; no arbitrary shell, shutdown, reboot, SSH/network/firewall/kernel/driver/partition operations", + } + if subject == "git_status": + return {"status": run(["git", "status", "--short", "--branch"], cwd=REPOSITORY), "diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)} + if subject == "containers": + return {"containers": run(["docker", "ps", "-a", "--format", "{{.Names}}\t{{.Status}}\t{{.Image}}"])} + if subject == "models": + entries = [] + if MODELS.is_dir(): + for path in sorted(MODELS.rglob("*.gguf")): + entries.append({"path": str(path.relative_to(MODELS)), "bytes": path.stat().st_size}) + return {"models": entries[:500], "count": len(entries)} + if subject == "jobs": + jobs = [] + for path in sorted((STATE / "jobs").glob("*.json"), reverse=True)[:30]: + jobs.append(json.loads(path.read_text())) + return {"jobs": jobs} + raise ValueError("unsupported inspection subject") + + +def read_source(arguments: dict[str, Any]) -> dict[str, Any]: + ensure_repository() + relative = safe_relative(str(arguments.get("path", ""))) + target = source_file(REPOSITORY, relative) + if not target.is_file(): + raise FileNotFoundError("source file not found") + text = target.read_text(encoding="utf-8", errors="replace") + start = max(1, int(arguments.get("start_line", 1))) + count = min(1000, max(1, int(arguments.get("line_count", 300)))) + lines = text.splitlines() + return {"path": str(relative), "sha256": sha(target.read_bytes()), "start_line": start, "content": "\n".join(lines[start - 1:start - 1 + count]), "total_lines": len(lines)} + + +def search_source(arguments: dict[str, Any]) -> dict[str, Any]: + ensure_repository() + query = str(arguments.get("query", "")) + if not query or len(query) > 200 or any(x in query for x in ("\x00", "\n", "\r")): + raise ValueError("invalid query") + result = run(["rg", "-n", "--hidden", "--glob", "!.git/**", "--", query, "."], cwd=REPOSITORY, timeout=20) + return {"query": query, "matches": result["output"], "exit_code": result["exit_code"]} + + +def normalise_operation(operation: str, payload: dict[str, Any]) -> tuple[dict[str, Any], str]: + if operation not in ALLOWED_OPERATIONS: + raise ValueError("unsupported operation") + if not isinstance(payload, dict): + raise ValueError("payload must be an object") + if operation == "file_update": + files = payload.get("files") + if not isinstance(files, list) or not 1 <= len(files) <= MAX_FILES: + raise ValueError("files must contain 1..24 entries") + normal = [] + previews = [] + for item in files: + relative = safe_relative(str(item.get("path", ""))) + content = str(item.get("content", "")) + raw = content.encode() + if len(raw) > MAX_FILE_BYTES: + raise ValueError("file content exceeds limit") + target = source_file(REPOSITORY, relative) + before = target.read_text(encoding="utf-8", errors="replace") if target.is_file() else "" + expected = str(item.get("expected_sha256", "")) + before_sha = sha(before.encode()) + if expected and expected != before_sha: + raise RuntimeError(f"source drift for {relative}") + diff = "".join(difflib.unified_diff(before.splitlines(True), content.splitlines(True), fromfile=f"a/{relative}", tofile=f"b/{relative}")) + normal.append({"path": str(relative), "content": content, "before_sha256": before_sha, "after_sha256": sha(raw)}) + previews.append(compact(diff)) + return {"files": normal}, "\n".join(previews) + if operation == "run_checks": + checks = payload.get("checks") or [] + if not isinstance(checks, list) or not checks or any(name not in ALLOWED_CHECKS for name in checks): + raise ValueError("unknown check suite") + return {"checks": checks}, "Will run: " + ", ".join(checks) + if operation == "compose_deploy": + compose_file = str(payload.get("compose_file", "")) + if compose_file not in {"compose.yaml", "platform/mcp/compose.yaml"}: + raise ValueError("unsupported compose file") + services = payload.get("services") or [] + if not isinstance(services, list) or not 1 <= len(services) <= 12 or any(not SAFE_NAME.fullmatch(str(x)) for x in services): + raise ValueError("invalid compose service list") + return {"compose_file": compose_file, "services": services, "build": bool(payload.get("build", True))}, f"docker compose -f {compose_file} up -d {'--build ' if payload.get('build', True) else ''}{' '.join(services)}" + if operation == "container_action": + action = str(payload.get("action", "")) + containers = payload.get("containers") or [] + if action not in {"start", "stop", "restart"}: + raise ValueError("invalid container action") + if not isinstance(containers, list) or not 1 <= len(containers) <= 12: + raise ValueError("invalid container list") + for name in containers: + if not SAFE_NAME.fullmatch(str(name)) or not str(name).startswith("mike-ai-") or name in PROTECTED_CONTAINERS: + raise PermissionError(f"container is outside operator boundary: {name}") + return {"action": action, "containers": containers}, f"docker {action} {' '.join(containers)}" + if operation == "openwebui_sync": + return {}, ( + "Synchronise the versioned Open WebUI model profiles, filters, tool connections " + "and system prompt with the running Open WebUI instance." + ) + if operation == "git_publish": + message = str(payload.get("message", "")) + if not SAFE_COMMIT.fullmatch(message): + raise ValueError("invalid commit message") + status = run(["git", "status", "--short"], cwd=REPOSITORY, check=True)["output"].strip() + if not status: + raise RuntimeError("repository has no changes to publish") + diff = run(["git", "diff", "--stat"], cwd=REPOSITORY, check=True)["output"] + preview = f"Commit message: {message}\n\nChanged and untracked files:\n{status}\n\nDiff summary:\n{diff}" + return {"message": message, "reviewed_status": status}, preview + if operation == "model_download": + url = str(payload.get("url", "")) + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https" or parsed.hostname not in {"huggingface.co", "cdn-lfs.huggingface.co", "hf.co"}: + raise PermissionError("only HTTPS Hugging Face downloads are allowed") + destination = safe_relative(str(payload.get("destination", ""))) + expected = str(payload.get("sha256", "")) + if expected and not re.fullmatch(r"[0-9a-f]{64}", expected): + raise ValueError("invalid sha256") + return {"url": url, "destination": str(destination), "sha256": expected}, f"Download {url} to models/{destination}" + if operation == "benchmark": + script = safe_relative(str(payload.get("script", ""))) + if not (str(script).startswith("dev/") or str(script).startswith("platform/bench")) or script.suffix not in {".py", ".sh"}: + raise PermissionError("benchmark script must be versioned below dev/ or platform/bench*") + args = payload.get("arguments") or [] + if not isinstance(args, list) or len(args) > 20 or any(not isinstance(x, str) or len(x) > 200 or re.search(r"[\x00\n\r]", x) for x in args): + raise ValueError("invalid benchmark arguments") + return {"script": str(script), "arguments": args}, f"Run versioned benchmark {script} with {args!r}" + if operation == "recovery": + label = str(payload.get("label", time.strftime("%Y%m%d"))) + if not SAFE_NAME.fullmatch(label): + raise ValueError("invalid recovery label") + return {"label": label}, f"Create encrypted recovery bundle and self-contained data kit: {label}" + raise AssertionError(operation) + + +def prepare(arguments: dict[str, Any]) -> dict[str, Any]: + ensure_repository() + operation = str(arguments.get("operation", "")) + payload, preview = normalise_operation(operation, arguments.get("payload") or {}) + ticket_id = uuid.uuid4().hex + record = {"id": ticket_id, "operation": operation, "payload": payload, "preview": preview, "created": now(), "expires": now() + TICKET_TTL, "status": "pending"} + record["binding"] = sha(json.dumps({"operation": operation, "payload": payload}, sort_keys=True, ensure_ascii=False).encode()) + json_write(STATE / "pending" / f"{ticket_id}.json", record) + audit("prepared", ticket=ticket_id, operation=operation, binding=record["binding"]) + return {"ticket": ticket_id, "operation": operation, "binding": record["binding"], "preview": preview, "expires_in_seconds": TICKET_TTL, "required_confirmation": f"EXECUTE {ticket_id}", "instruction": "Show the full preview to the user and wait for explicit confirmation in a later message."} + + +def sync_file(relative: Path, content: str, backup_root: Path) -> None: + for root in (REPOSITORY, STACK): + target = source_file(root, relative) + if target.exists(): + backup = backup_root / root.name / relative + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(target, backup) + target.parent.mkdir(parents=True, exist_ok=True) + fd, temp_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + os.replace(temp_name, target) + + +def start_job(ticket: str, operation: str, worker) -> dict[str, Any]: + job_id = uuid.uuid4().hex + job_path = STATE / "jobs" / f"{job_id}.json" + json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "running", "started": now()}) + def wrapped(): + try: + result = worker() + json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "completed", "started": json.loads(job_path.read_text())["started"], "finished": now(), "result": result}) + audit("job_completed", job=job_id, operation=operation) + except Exception as exc: + json_write(job_path, {"id": job_id, "ticket": ticket, "operation": operation, "status": "failed", "finished": now(), "error": compact(str(exc))}) + audit("job_failed", job=job_id, operation=operation) + threading.Thread(target=wrapped, daemon=True).start() + return {"job_id": job_id, "status": "running", "instruction": "Poll athena_operator_job until completed or failed."} + + +def execute_operation(ticket: str, operation: str, payload: dict[str, Any]) -> dict[str, Any]: + if operation == "file_update": + backup = STATE / "backups" / f"{now()}-{ticket}" + for item in payload["files"]: + relative = safe_relative(item["path"]) + current = source_file(REPOSITORY, relative) + current_sha = sha(current.read_bytes()) if current.is_file() else sha(b"") + if current_sha != item["before_sha256"]: + raise RuntimeError(f"source drift after preview: {relative}") + for item in payload["files"]: + sync_file(safe_relative(item["path"]), item["content"], backup) + return {"changed": [item["path"] for item in payload["files"]], "backup": str(backup), "git_diff": run(["git", "diff", "--stat"], cwd=REPOSITORY)} + if operation == "run_checks": + return {"checks": [{"name": name, **run(ALLOWED_CHECKS[name], cwd=REPOSITORY, timeout=1200)} for name in payload["checks"]]} + if operation == "compose_deploy": + compose = payload["compose_file"] + run(["docker", "compose", "-f", compose, "config", "-q"], cwd=STACK, check=True) + argv = ["docker", "compose", "-f", compose, "up", "-d"] + if payload["build"]: + argv.append("--build") + argv.extend(payload["services"]) + return {"deploy": run(argv, cwd=STACK, timeout=3600, check=True), "containers": run(["docker", "ps", "--format", "{{.Names}}\t{{.Status}}"])} + if operation == "container_action": + return {"action": run(["docker", payload["action"], *payload["containers"]], timeout=300, check=True)} + if operation == "openwebui_sync": + installer = STACK / "platform/openwebui/install-filters.sh" + if not installer.is_file(): + raise FileNotFoundError("versioned Open WebUI synchronisation script is missing") + return {"sync": run(["bash", str(installer)], cwd=STACK, timeout=1800, check=True)} + if operation == "git_publish": + current_status = run(["git", "status", "--short"], cwd=REPOSITORY, check=True)["output"].strip() + if current_status != payload["reviewed_status"]: + raise RuntimeError("repository changed after the Git publish preview") + run(["git", "add", "--all"], cwd=REPOSITORY, check=True) + run(["git", "diff", "--cached", "--check"], cwd=REPOSITORY, check=True) + commit = run(["git", "commit", "-m", payload["message"]], cwd=REPOSITORY, check=True) + pushed = run(["git", "push", "origin", "HEAD:main"], cwd=REPOSITORY, timeout=300, check=True) + head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() + (STACK / ".mike-ai-source-commit").write_text(head + "\n") + return {"commit": head, "commit_output": commit, "push_output": pushed} + if operation == "model_download": + def download(): + destination = source_file(MODELS, Path(payload["destination"])) + destination.parent.mkdir(parents=True, exist_ok=True) + partial = destination.with_suffix(destination.suffix + ".partial") + request = urllib.request.Request(payload["url"], headers={"User-Agent": "Athena-Operator/1"}) + digest = hashlib.sha256() + total = 0 + with urllib.request.urlopen(request, timeout=60) as response, partial.open("wb") as output: + while chunk := response.read(8 * 1024 * 1024): + output.write(chunk); digest.update(chunk); total += len(chunk) + actual = digest.hexdigest() + if payload["sha256"] and actual != payload["sha256"]: + partial.unlink(missing_ok=True); raise RuntimeError("model checksum mismatch") + os.replace(partial, destination) + return {"destination": str(destination), "bytes": total, "sha256": actual} + return start_job(ticket, operation, download) + if operation == "benchmark": + def benchmark(): + script = source_file(REPOSITORY, safe_relative(payload["script"])) + interpreter = "python3" if script.suffix == ".py" else "bash" + return run([interpreter, str(script), *payload["arguments"]], cwd=REPOSITORY, timeout=86400) + return start_job(ticket, operation, benchmark) + if operation == "recovery": + def recovery(): + label = payload["label"] + dirty = run(["git", "status", "--porcelain"], cwd=REPOSITORY, check=True)["output"].strip() + if dirty: + raise RuntimeError("publish repository changes before creating a recovery kit") + head = run(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, check=True)["output"].strip() + marker = (STACK / ".mike-ai-source-commit").read_text().strip() + if marker != head: + raise RuntimeError("deployed source marker and operator repository HEAD differ") + encrypted = Path(f"/data/athena-recovery-{label}.tar.age") + source_bundle = Path(f"/data/athena-source-{label}.git.bundle") + release = Path(f"/data/mike-ai-recovery-kit-{label}") + for target in (encrypted, source_bundle, release): + if target.exists(): + raise FileExistsError(target) + git_bundle = run(["git", "bundle", "create", str(source_bundle), "--all"], cwd=REPOSITORY, timeout=1800, check=True) + encrypted_result = run([str(STACK / "platform/recovery/create-recovery-bundle.sh"), str(encrypted)], timeout=7200, check=True) + identity = Path("/data/mike-ai-recovery-kit/recovery.agekey") + if not identity.is_file(): + raise RuntimeError("existing recovery identity is unavailable") + kit_result = run([ + str(STACK / "platform/recovery/create-self-contained-data-kit.sh"), + str(encrypted), str(identity), str(source_bundle), str(release), + ], timeout=7200, check=True) + verify = run(["sha256sum", "-c", "SHA256SUMS"], cwd=release, timeout=1800, check=True) + return { + "commit": head, + "recovery_bundle": str(encrypted), + "source_bundle": str(source_bundle), + "self_contained_kit": str(release), + "git_bundle": git_bundle, + "encrypted_bundle": encrypted_result, + "kit": kit_result, + "verification": verify, + } + return start_job(ticket, operation, recovery) + raise AssertionError(operation) + + +def execute(arguments: dict[str, Any]) -> dict[str, Any]: + ticket = str(arguments.get("ticket", "")) + confirmation = str(arguments.get("confirmation", "")) + if not re.fullmatch(r"[0-9a-f]{32}", ticket) or confirmation != f"EXECUTE {ticket}": + raise PermissionError("exact ticket confirmation required") + path = STATE / "pending" / f"{ticket}.json" + if not path.is_file(): + raise ValueError("ticket not found or already consumed") + with LOCK: + record = json.loads(path.read_text()) + if record["status"] != "pending" or now() > record["expires"]: + raise PermissionError("ticket expired or unavailable") + expected = sha(json.dumps({"operation": record["operation"], "payload": record["payload"]}, sort_keys=True, ensure_ascii=False).encode()) + if expected != record["binding"]: + raise RuntimeError("ticket binding mismatch") + record["status"] = "executing" + json_write(path, record) + try: + result = execute_operation(ticket, record["operation"], record["payload"]) + record["status"] = "executed" + record["executed"] = now() + record["result_summary"] = compact(json.dumps(result, ensure_ascii=False)) + completed = STATE / "completed" / path.name + json_write(completed, record) + path.unlink() + audit("executed", ticket=ticket, operation=record["operation"], binding=record["binding"]) + return {"ticket": ticket, "operation": record["operation"], "result": result, "instruction": "Verify health and Git/recovery state before declaring the work complete."} + except Exception: + record["status"] = "failed" + json_write(path, record) + raise + + +def job(arguments: dict[str, Any]) -> dict[str, Any]: + job_id = str(arguments.get("job_id", "")) + if not re.fullmatch(r"[0-9a-f]{32}", job_id): + raise ValueError("invalid job id") + path = STATE / "jobs" / f"{job_id}.json" + if not path.is_file(): + raise FileNotFoundError("job not found") + return json.loads(path.read_text()) + + +def dispatch(request: dict[str, Any]) -> dict[str, Any]: + action = request.get("action") + arguments = request.get("arguments") or {} + if action == "inspect": return inspect(str(arguments.get("subject", "overview")), arguments) + if action == "read_source": return read_source(arguments) + if action == "search_source": return search_source(arguments) + if action == "prepare": return prepare(arguments) + if action == "execute": return execute(arguments) + if action == "job": return job(arguments) + raise ValueError("unsupported operator action") + + +class Handler(socketserver.StreamRequestHandler): + def handle(self) -> None: + line = self.rfile.readline(2_000_000) + try: + request = json.loads(line) + result = dispatch(request) + response = {"ok": True, "result": result} + except Exception as exc: + response = {"ok": False, "error": compact(str(exc))} + self.wfile.write((json.dumps(response, ensure_ascii=False, separators=(",", ":")) + "\n").encode()) + + +def main() -> None: + STATE.mkdir(parents=True, exist_ok=True) + SOCKET.parent.mkdir(parents=True, exist_ok=True) + SOCKET.unlink(missing_ok=True) + server = socketserver.ThreadingUnixStreamServer(str(SOCKET), Handler) + os.chmod(SOCKET, 0o660) + os.chown(SOCKET, 0, int(os.environ.get("ATHENA_OPERATOR_GID", "10003"))) + audit("daemon_started", version=VERSION) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/platform/operator/install-operator.sh b/platform/operator/install-operator.sh new file mode 100644 index 0000000..54d4b90 --- /dev/null +++ b/platform/operator/install-operator.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +[[ $EUID -eq 0 ]] || { echo "Bitte als root ausführen." >&2; exit 1; } +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +install -d -m 0700 /etc/mike-ai +if [[ ! -e /etc/mike-ai/athena-operator.env ]]; then + install -m 0600 "$ROOT/config/athena-operator.env.example" /etc/mike-ai/athena-operator.env +fi +install -d -m 0750 -o root -g 10003 /run/mike-ai-operator +install -d -m 0700 /data/mike-ai-operator/state /data/mike-ai-operator/repository +install -m 0755 "$ROOT/platform/operator/athena_operatord.py" /usr/local/libexec/mike-ai-athena-operatord +install -m 0644 "$ROOT/platform/operator/athena-operator.service" /etc/systemd/system/mike-ai-athena-operator.service +systemctl daemon-reload +systemctl enable --now mike-ai-athena-operator.service +systemctl is-active --quiet mike-ai-athena-operator.service +echo ATHENA_OPERATOR_EXECUTOR_OK