Add controlled Athena platform operator
This commit is contained in:
@@ -19,7 +19,8 @@ WireGuard-Isolation.
|
|||||||
- Open WebUI als einzige normale Oberfläche
|
- Open WebUI als einzige normale Oberfläche
|
||||||
- SearXNG/Web-MCP ohne externen API-Schlüssel
|
- SearXNG/Web-MCP ohne externen API-Schlüssel
|
||||||
- zentrale MCP-Werkzeugebene: getrennte Container für Athena-Plattformwissen,
|
- 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
|
Clients
|
||||||
- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar
|
- KI-Dienste ausschließlich über den containerisierten WireGuard-Gateway erreichbar
|
||||||
- KI-Ausgangsverkehr über das Heimnetz, bei Tunnelausfall fail-closed
|
- 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 |
|
| XTTS-v2 | nur Docker-intern, RTX 3060 | primäre mehrsprachige Sprachausgabe |
|
||||||
| TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback |
|
| TTS Gateway | nur Docker-intern | Annmarie Nele, Queue und Piper-Fallback |
|
||||||
| Piper | nur Docker-intern, CPU | ausfallsichere deutsche Ersatzstimme |
|
| 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
|
XTTS-v2, TTS-Gateway, Piper-Fallback und der FLUX.2-Klein-Hot-Swap sind
|
||||||
reproduzierbare Kerndienste; STT
|
reproduzierbare Kerndienste; STT
|
||||||
@@ -78,6 +79,20 @@ keine Modell-Tokens und verraten dem Modell keine zusätzlichen Daten.
|
|||||||
|
|
||||||
## Dokumentation
|
## 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/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/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
|
- [`docs/PLATFORM_CONTEXT_MCP.md`](docs/PLATFORM_CONTEXT_MCP.md) – profilunabhängiges Plattformwissen und kontrollierte Dokumentationspflege
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
complete until Git commit/push and the refreshed recovery kit are separately
|
||||||
verified.
|
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
|
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,
|
shut down, reboot, power off, alter SSH, lan0, firewall, routing, WireGuard,
|
||||||
kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts,
|
kernel, NVIDIA drivers, initramfs, bootloader, filesystems, partitions, mounts,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
|
||||||
@@ -170,12 +170,12 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(result["tool_ids"], ["server:mcp:athena-platform"])
|
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(
|
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(
|
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):
|
async def test_mcp_build_from_github_gets_source_and_platform_context(self):
|
||||||
@@ -184,7 +184,7 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result["tool_ids"],
|
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):
|
async def test_github_and_explicit_web_are_bounded_to_two(self):
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
| Navidrome-MCP | Blakeem/Navidrome-MCP 2.2.0, Image per OCI-Digest | eigener optionaler Container ohne mpv | optional |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Unraid-MCP | lokales `runraid`-Binary | eigener optionaler Container | optional |
|
||||||
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
|
| Whisper | ggml-org/whisper.cpp | Service im Router-Deploy | optional |
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ Der isolierte Eignungs- und Ausfalltest ist in
|
|||||||
Aktuell existieren funktionale Adapter für:
|
Aktuell existieren funktionale Adapter für:
|
||||||
|
|
||||||
- Athena-Plattformwissen, Laufzeitsnapshot und kontrollierte Docs-Pflege
|
- 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
|
- Websuche
|
||||||
- Home Assistant
|
- Home Assistant
|
||||||
- Sonarr/Radarr
|
- 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
|
Der Schreibpfad ist auf `docs/*.md`, Vorschau, ausdrückliche Freigabe, atomare
|
||||||
Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt.
|
Sicherung und sichtbare Git-/Recovery-Nacharbeit begrenzt.
|
||||||
|
|
||||||
Der ergänzende Athena Terminal MCP ist kein Host-Terminal. Er sieht nur den
|
Der Athena Operator MCP ersetzt die frühere begrenzte Terminal-Fassade. Er ist
|
||||||
read-only Stack und Laufzeitsnapshot und besitzt eine Positivliste weniger
|
die zusammenhängende Bedienebene, mit der Qwen die KI-Plattform selbst
|
||||||
Lesebefehle. SSH/SCP, Docker, Netzwerkclients, Interpreter, `systemctl`,
|
weiterentwickeln und betreiben kann. Quellenlesen, Dateiänderungen, Tests,
|
||||||
Prozesssignale, Reboot, Shutdown, Secrets und Schreibzugriffe sind nicht
|
Compose-Deployments, Containeraktionen, Modell-Downloads, Benchmarks,
|
||||||
verfügbar. Der Auto Tool Selector lädt ihn nur bei ausdrücklich auf Athena
|
Git-Publishing und Recovery sind strukturiert verfügbar. Zustandsänderungen
|
||||||
bezogener Terminal-/Shell-Arbeit.
|
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
|
Der frühere allgemeine Shell-MCP und doppelte, schreibende Werkzeuge gehören
|
||||||
nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt.
|
nicht zum Sicherheitsziel und werden nicht ungeprüft wiederhergestellt.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
|
|||||||
- [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet
|
- [ ] Filter und Quick Actions sind allen fünf Presets zugeordnet
|
||||||
- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home
|
- [ ] Auto Tool Selector wählt bei harmlosen Testfragen Web, GitHub, Home
|
||||||
Assistant, ARR, Navidrome, Unraid read-only, Athena-Plattformwissen und
|
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
|
- [ ] normale Unterhaltung erhält kein MCP; MUA wird niemals automatisch gewählt
|
||||||
|
|
||||||
- [ ] SearXNG und TinySearch gesund
|
- [ ] SearXNG und TinySearch gesund
|
||||||
@@ -79,8 +79,9 @@ laufen, sondern alle fachlichen Funktionen geprüft wurden.
|
|||||||
System-Prompt entsprechen dem wiederhergestellten Stand
|
System-Prompt entsprechen dem wiederhergestellten Stand
|
||||||
- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte
|
- [ ] Platform-Context-Snapshot aktuell; offene Vorschläge und angewandte
|
||||||
Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft
|
Dokumentationsänderungen mit `athena_get_maintenance_status` geprüft
|
||||||
- [ ] Athena-Terminal gesund; erlaubter Leseaufruf erfolgreich; SSH, Docker,
|
- [ ] Athena Operator und rootseitiger Executor gesund; Lesen und Preview
|
||||||
Reboot, Shutdown und Pfadausbruch in Negativtests verweigert
|
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
|
- [ ] lokales Dokumentations-Overlay ist auch im privaten Git enthalten und
|
||||||
der Recovery-Koffer wurde danach neu erzeugt
|
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
|
- [ ] Router nur aus erlaubtem Netz erreichbar
|
||||||
- [ ] Dienste laufen mit minimalen Rechten
|
- [ ] Dienste laufen mit minimalen Rechten
|
||||||
- [ ] Environment-Dateien Modus 0600
|
- [ ] Environment-Dateien Modus 0600
|
||||||
- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena-Terminal besitzt nur
|
- [ ] kein allgemeiner Shell-MCP im Standardprofil; Athena Operator besitzt nur
|
||||||
die dokumentierte Positivliste und zwei read-only Mounts
|
strukturierte, ticketgebundene Plattformoperationen
|
||||||
- [ ] Schreibaktionen verlangen Vorschau und Approval Ticket
|
- [ ] Schreibaktionen verlangen Vorschau und Approval Ticket
|
||||||
- [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt
|
- [ ] Secret-Restore wurde ohne Klartextausgabe durchgeführt
|
||||||
- [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena
|
- [ ] verschlüsseltes Recovery-Bundle liegt außerhalb von Athena
|
||||||
|
|||||||
+20
-1
@@ -143,14 +143,33 @@ zentralen Secret-, Prompt-Injection- und Tool-Output-Filter bleiben aktiv.
|
|||||||
Clients verbinden sich mit:
|
Clients verbinden sich mit:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://<FRITZ-VPN-IP>: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
|
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,
|
Bearer-Authentifizierung. Der Key gehört in den Secret-Store des Clients,
|
||||||
nicht in Chat, Repository oder URL. Eine Rotation erfolgt atomar durch
|
nicht in Chat, Repository oder URL. Eine Rotation erfolgt atomar durch
|
||||||
Ersetzen der Datei und Neustart des Routerdienstes.
|
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,
|
Sie sollen nicht direkt Port 8080 verwenden, weil sie sonst Profilumschaltung,
|
||||||
Vision, Bildgenerierung, STT und TTS umgehen.
|
Vision, Bildgenerierung, STT und TTS umgehen.
|
||||||
|
|
||||||
|
|||||||
@@ -96,11 +96,13 @@ Der offizielle GitHub-MCP bietet nur vier Werkzeuge:
|
|||||||
|
|
||||||
Andere GitHub-Werkzeuge sowie Schreibzugriffe sind serverseitig deaktiviert.
|
Andere GitHub-Werkzeuge sowie Schreibzugriffe sind serverseitig deaktiviert.
|
||||||
|
|
||||||
Für gezielte Beweissuche im Athena-Stack existiert zusätzlich eine begrenzte
|
Für Entwicklung und Betrieb der KI-Plattform existiert ein zentraler Athena
|
||||||
Terminal-Fassade. Sie ist kein allgemeiner Shell-MCP: Nur fest erlaubte
|
Operator MCP. Eine unprivilegierte MCP-Fassade spricht ausschließlich über
|
||||||
Lesebefehle gegen zwei read-only Mounts sind möglich. Docker, SSH, Netzwerk,
|
einen Unix-Socket mit einem rootseitigen strukturierten Executor. Dadurch kann
|
||||||
Secrets, Service-/Power-Steuerung und jede Form von Schreiben bleiben außerhalb
|
Qwen MCPs, Docker-Dienste, Modelle, Profile, OpenWebUI, Tests, Git und Recovery
|
||||||
dieser Vertrauensgrenze.
|
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
|
## Verbindliche Quellen
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ Netzzugriff. Kein MCP-Port wird am Host veröffentlicht.
|
|||||||
| Bereich | Aufgabe | Rechte |
|
| Bereich | Aufgabe | Rechte |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Athena-Plattform | Architektur, Quellen, Laufzeitsnapshot, Dokumentationspflege | Lesen; Markdown nur Preview/Approval |
|
| 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 |
|
| Web | aktuelle öffentliche Recherche über SearXNG/TinySearch | read-only |
|
||||||
| GitHub | Repositorysuche, Baum, Dateiinhalt, Code-Suche | strikt read-only, vier Tools |
|
| 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 |
|
| 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 |
|
| Unraid | Host-, Docker-, Array-, Netzwerk- und Logdiagnose | read-only Standard |
|
||||||
| MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren |
|
| MUA/Admin | eng definierte Unraid-Verwaltung | bewusst aktivieren |
|
||||||
|
|
||||||
`Athena Terminal` ist trotz seines Namens keine allgemeine Shell. Das Werkzeug
|
`Athena Operator` ist die zentrale Arbeitsumgebung für Änderungen an Athena.
|
||||||
stellt ausschließlich `ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`,
|
Nutze ihn zum Lesen der tatsächlichen Quellen, Erstellen und Anwenden von
|
||||||
`sha256sum`, `grep`, `rg`, `sed`, `du`, `df` und reine Syntaxprüfung bereit.
|
Dateiänderungen, Testen, Deployen von Compose-Diensten und MCPs, Verwalten der
|
||||||
Es kann weder Dateien ändern noch Docker, SSH, Netzwerk, Dienste, Prozesse,
|
MikeAI-Container, Laden und Prüfen von Modellen, Starten versionierter
|
||||||
Reboot oder Shutdown steuern. Fordere keine Umgehung dieser Grenze an und
|
Benchmarks, Git-Publishing und Recovery. Änderungen erfolgen stets in zwei
|
||||||
behaupte nach einem Leseaufruf niemals, eine Änderung deployed zu haben.
|
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
|
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:
|
reinen stdio-zu-Streamable-HTTP-Brücke. Aktiv sind ausschließlich:
|
||||||
|
|||||||
+8
-5
@@ -54,11 +54,14 @@ Sandbox. Er ist klein, testbar und nicht von Clients direkt erreichbar.
|
|||||||
| Administration | Vorschau, Approval-Ticket, Verifikation |
|
| Administration | Vorschau, Approval-Ticket, Verifikation |
|
||||||
|
|
||||||
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und
|
Allgemeine Shell, beliebiges SSH/SCP, freies `curl`, Docker-Administration und
|
||||||
freie Dateisystemsuche gehören nicht ins Standardprofil. Der Athena Terminal
|
freie Dateisystemsuche gehören nicht ins Standardprofil. Für die Athena-
|
||||||
MCP ist davon ausdrücklich keine Ausnahme: Er sieht nur zwei read-only Mounts,
|
Plattform existiert genau ein Operator-MCP. Seine unprivilegierte Fassade sieht
|
||||||
besitzt weder Docker-Socket noch Egress oder Host-PID-Namensraum und akzeptiert
|
nur einen lokalen Unix-Socket; ein rootseitiger Executor besitzt die für
|
||||||
nur eine kleine Positivliste direkter Lesebefehle. Reboot, Shutdown, SSH,
|
Repository, Docker, Modelle, Git und Recovery notwendigen Rechte. Er bietet
|
||||||
Interpreter, Service-Steuerung und sämtliche Schreibwege sind ausgeschlossen.
|
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
|
## Schreibaktionen
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
for optional in mike-ai-mcp-web mike-ai-mcp-homeassistant mike-ai-mcp-arr \
|
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
|
mike-ai-mcp-unraid-official; do
|
||||||
if container_healthy "$optional"; then
|
if container_healthy "$optional"; then
|
||||||
pass "$optional aktiv"
|
pass "$optional aktiv"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
|
||||||
+13
-11
@@ -11,7 +11,7 @@ Prompts heraus, verhindert den früher beobachteten Kontextverbrauch von über
|
|||||||
| Container | Endpunkt im Netz `mike-ai-tools` | Zweck | Standard |
|
| 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-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-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-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` |
|
| `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:
|
Preview/Approval-Ablauf begrenzt. Vollständige Beschreibung:
|
||||||
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
|
[`docs/PLATFORM_CONTEXT_MCP.md`](../../docs/PLATFORM_CONTEXT_MCP.md).
|
||||||
|
|
||||||
Der Athena Terminal MCP ist ebenfalls **keine allgemeine Shell**. Er führt nur
|
Der Athena Operator MCP ist die einzige Bedienebene für Arbeiten an der lokalen
|
||||||
eine kurze serverseitige Positivliste von Lesewerkzeugen mit direkten
|
KI-Plattform. Qwen kann damit Quellen lesen, Änderungen vorbereiten, MCPs und
|
||||||
Argumenten aus (`ls`, `cat`, `head`, `tail`, `wc`, `stat`, `file`,
|
Docker-Dienste bauen/deployen, Modelle laden, Benchmarks starten, Profile und
|
||||||
`sha256sum`, `grep`, `rg`, `sed`, `du`, `df`). Sichtbar sind ausschließlich
|
OpenWebUI pflegen, Git veröffentlichen und Recovery erzeugen. Die unprivilegierte
|
||||||
der read-only eingebundene versionierte Stack und der begrenzte
|
MCP-Fassade sieht dabei nur einen lokalen Unix-Socket. Docker-Socket,
|
||||||
Laufzeitsnapshot. Shell-Syntax, Interpreter, Netzwerkprogramme, SSH/SCP,
|
Repository, Modellverzeichnis, Git-Zugang und Root-Rechte verbleiben im
|
||||||
Docker, `systemctl`, Prozesssignale, Reboot und Shutdown sind nicht vorhanden
|
rootseitigen Executor. Jede Änderung benötigt eine vollständige Vorschau, ein
|
||||||
beziehungsweise werden bereits vor der Ausführung abgewiesen.
|
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
|
TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
|
||||||
`/home/tinysearch/.crawl4ai` ist beschreibbar, weil Crawl4AI dort seinen
|
`/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 |
|
| Serien, Filme, fehlende Episoden und Indexer-Releases | Sonarr und Radarr | Web |
|
||||||
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
|
| Persönliche Musikbibliothek, Titel, Alben, Künstler und Playlists | Navidrome | Web, ARR |
|
||||||
| Lesende NAS-, Docker-, Array-, Netzwerk- und Logdiagnose | Unraid (Systemdiagnose) | MUA |
|
| 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 |
|
| Ausdrücklich benötigte MUA-Verwaltungsaktion | MUA | Unraid-Diagnose nicht parallel |
|
||||||
|
|
||||||
Ein leeres Ergebnis ist kein Grund, dieselbe Frage über mehrere unpassende
|
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`.
|
`no-new-privileges`.
|
||||||
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
|
- Der SSH-basierte Unraid-Container ist nicht Teil des Standardstarts.
|
||||||
- Ein allgemeiner Host-Shell-MCP wird weiterhin bewusst nicht angeboten. Der
|
- 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
|
## Start
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
|
||||||
@@ -166,22 +166,20 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
mcp-athena-terminal:
|
mcp-athena-operator:
|
||||||
<<: *tool-common
|
<<: *tool-common
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.athena-terminal
|
dockerfile: Dockerfile.athena-operator
|
||||||
image: mike-ai/mcp-athena-terminal:1.0.0
|
image: mike-ai/mcp-athena-operator:1.0.0
|
||||||
container_name: mike-ai-mcp-athena-terminal
|
container_name: mike-ai-mcp-athena-operator
|
||||||
environment:
|
environment:
|
||||||
ATHENA_TERMINAL_WORKSPACE: /workspace
|
ATHENA_OPERATOR_SOCKET: /operator/operator.sock
|
||||||
ATHENA_TERMINAL_RUNTIME: /runtime
|
|
||||||
volumes:
|
volumes:
|
||||||
# The terminal sees only versioned source and the bounded, payload-free
|
# The unprivileged MCP facade sees only the root-owned executor socket.
|
||||||
# runtime snapshot. It receives no Docker socket, host filesystem,
|
# Docker, source, models, Git credentials and host paths remain on the
|
||||||
# secrets, SSH material, devices, PID namespace or egress network.
|
# executor side and are reachable only through structured operations.
|
||||||
- ${PLATFORM_STACK_DIR:-/opt/mike-ai/stack}:/workspace:ro
|
- /run/mike-ai-operator:/operator:ro
|
||||||
- ${PLATFORM_CONTEXT_RUNTIME_DIR:-/var/lib/mike-ai-platform-context}:/runtime:ro
|
|
||||||
networks: [tools]
|
networks: [tools]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
test: ["CMD", "python", "-c", "import socket; s=socket.create_connection(('127.0.0.1',8000),2); s.close()"]
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ systemctl daemon-reload
|
|||||||
systemctl enable --now mike-ai-platform-context-snapshot.timer
|
systemctl enable --now mike-ai-platform-context-snapshot.timer
|
||||||
systemctl start mike-ai-platform-context-snapshot.service
|
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=()
|
profiles=()
|
||||||
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
|
if [[ -s /etc/mike-ai/homeassistant-admin-mcp.env ]]; then
|
||||||
profiles+=(--profile homeassistant)
|
profiles+=(--profile homeassistant)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
title: MikeAI Auto Tool Selector
|
title: MikeAI Auto Tool Selector
|
||||||
author: MikeAI
|
author: MikeAI
|
||||||
version: 1.1.0
|
version: 2.0.0
|
||||||
description: Selects a small, relevant set of MCP servers for each user request.
|
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",
|
"unraid": "server:mcp:unraid-readonly-local",
|
||||||
"navidrome": "server:mcp:navidrome-local",
|
"navidrome": "server:mcp:navidrome-local",
|
||||||
"platform": "server:mcp:athena-platform",
|
"platform": "server:mcp:athena-platform",
|
||||||
"terminal": "server:mcp:athena-terminal-local",
|
"operator": "server:mcp:athena-operator-local",
|
||||||
}
|
}
|
||||||
|
|
||||||
LABELS = {
|
LABELS = {
|
||||||
@@ -38,7 +38,7 @@ class Filter:
|
|||||||
"unraid": "Unraid-Diagnose",
|
"unraid": "Unraid-Diagnose",
|
||||||
"navidrome": "Navidrome",
|
"navidrome": "Navidrome",
|
||||||
"platform": "Athena-Plattformwissen",
|
"platform": "Athena-Plattformwissen",
|
||||||
"terminal": "Athena-Terminal (begrenzt, nur lesend)",
|
"operator": "Athena Operator",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -96,13 +96,14 @@ class Filter:
|
|||||||
|
|
||||||
selected: list[str] = []
|
selected: list[str] = []
|
||||||
|
|
||||||
terminal = self._matches(
|
operator = self._matches(
|
||||||
text,
|
text,
|
||||||
(
|
(
|
||||||
r"\bathena[- ]terminal\b",
|
r"\bathena[- ]operator\b",
|
||||||
r"\b(?:athena|ki[- ]host)\b.*\b(?:terminal|shell|kommando|befehl)\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(?:terminal|shell)\b.*\b(?:athena|ki[- ]host)\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"\b(?:ls|tail|grep|rg|cat)\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(
|
platform = self._matches(
|
||||||
@@ -114,8 +115,6 @@ class Filter:
|
|||||||
r"\b(?:disaster|bare metal)[- ]recovery\b",
|
r"\b(?:disaster|bare metal)[- ]recovery\b",
|
||||||
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
|
r"\b(?:installations?|reinstall|setup)[- ]skript\b",
|
||||||
r"\bplattform(?:wissen|dokumentation)?\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(
|
homeassistant = self._matches(
|
||||||
@@ -168,8 +167,8 @@ class Filter:
|
|||||||
|
|
||||||
# A specialist source is more precise than public web search. Platform
|
# A specialist source is more precise than public web search. Platform
|
||||||
# wins over a generic Docker mention when Athena is explicitly named.
|
# wins over a generic Docker mention when Athena is explicitly named.
|
||||||
if terminal:
|
if operator:
|
||||||
selected.append("terminal")
|
selected.append("operator")
|
||||||
elif platform:
|
elif platform:
|
||||||
selected.append("platform")
|
selected.append("platform")
|
||||||
elif homeassistant:
|
elif homeassistant:
|
||||||
|
|||||||
@@ -165,6 +165,18 @@ with con:
|
|||||||
connections = json.loads(row[0])
|
connections = json.loads(row[0])
|
||||||
if not isinstance(connections, list):
|
if not isinstance(connections, list):
|
||||||
raise SystemExit("Unbekanntes Format in tool_server.connections.")
|
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 = {
|
descriptions = {
|
||||||
"web-local": (
|
"web-local": (
|
||||||
"Web (öffentlich, read-only)",
|
"Web (öffentlich, read-only)",
|
||||||
@@ -212,15 +224,16 @@ with con:
|
|||||||
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
|
"Quellcode dieses Werkzeug statt allgemeiner Websuche verwenden. Keine Issues, "
|
||||||
"Pull Requests, Actions oder Schreibzugriffe.",
|
"Pull Requests, Actions oder Schreibzugriffe.",
|
||||||
),
|
),
|
||||||
"athena-terminal-local": (
|
"athena-operator-local": (
|
||||||
"Athena Terminal (begrenzt, nur lesend)",
|
"Athena Operator",
|
||||||
"Nur für gezielte Beweissuche in Athenas versioniertem Stack und einem "
|
"Zentrale Bedienebene für Athenas KI-Plattform: MCPs entwickeln und deployen, "
|
||||||
"begrenzten Laufzeitsnapshot. Unterstützt ausschließlich fest erlaubte "
|
"Docker-Dienste verwalten, Modelle laden und testen, Profile/OpenWebUI ändern, "
|
||||||
"Lesebefehle und Syntaxprüfung. Kein SSH, Docker, Netzwerk, Schreibzugriff, "
|
"prüfen, dokumentieren, versionieren und Recovery erzeugen. Änderungen benötigen "
|
||||||
"Reboot, Shutdown, systemctl, Interpreter oder Zugriff auf Secrets.",
|
"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:
|
for connection in connections:
|
||||||
if not isinstance(connection, dict):
|
if not isinstance(connection, dict):
|
||||||
continue
|
continue
|
||||||
@@ -244,8 +257,8 @@ with con:
|
|||||||
match = "navidrome-local"
|
match = "navidrome-local"
|
||||||
elif "mike-ai-mcp-github" in url:
|
elif "mike-ai-mcp-github" in url:
|
||||||
match = "github-local"
|
match = "github-local"
|
||||||
elif "mike-ai-mcp-athena-terminal" in url:
|
elif "mike-ai-mcp-athena-operator" in url:
|
||||||
match = "athena-terminal-local"
|
match = "athena-operator-local"
|
||||||
elif "mike-ai-mcp-unraid-official" in url:
|
elif "mike-ai-mcp-unraid-official" in url:
|
||||||
match = "unraid-readonly-local"
|
match = "unraid-readonly-local"
|
||||||
else:
|
else:
|
||||||
@@ -287,16 +300,16 @@ with con:
|
|||||||
isinstance(connection, dict)
|
isinstance(connection, dict)
|
||||||
and (
|
and (
|
||||||
str(connection.get("url", "")).lower()
|
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()
|
or str((connection.get("info") or {}).get("id", "")).lower()
|
||||||
== "athena-terminal-local"
|
== "athena-operator-local"
|
||||||
)
|
)
|
||||||
for connection in connections
|
for connection in connections
|
||||||
):
|
):
|
||||||
name, description = descriptions["athena-terminal-local"]
|
name, description = descriptions["athena-operator-local"]
|
||||||
connections.append(
|
connections.append(
|
||||||
{
|
{
|
||||||
"url": "http://mike-ai-mcp-athena-terminal:8000/mcp",
|
"url": "http://mike-ai-mcp-athena-operator:8000/mcp",
|
||||||
"path": "",
|
"path": "",
|
||||||
"type": "mcp",
|
"type": "mcp",
|
||||||
"auth_type": "none",
|
"auth_type": "none",
|
||||||
@@ -304,7 +317,7 @@ with con:
|
|||||||
"key": "",
|
"key": "",
|
||||||
"config": {"enable": True, "access_grants": []},
|
"config": {"enable": True, "access_grants": []},
|
||||||
"info": {
|
"info": {
|
||||||
"id": "athena-terminal-local",
|
"id": "athena-operator-local",
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": description,
|
"description": description,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user