# agent-codex XMPP-erreichbarer Codex-Agent für die Plattform, in zwei Rollen: **Agent** (Dialog, weiterhin read-only für alles außer den zwei seit 01.08.2026 namentlich erlaubten Ausnahmen unten) und **Builder** (kann seit 30.07.2026 tatsächlich Dateien ändern, committen und pushen). Rollen-Definitionen liegen in `agents/` im Repo-Root (`shared.md`, `agent.md`, `builder.md`, `AGENTS.md` als Loader) — die hier nicht duplizieren, dort lesen. ## Architektur ``` XMPP-Nachricht an agent@bot.0ff.space → agent-xmpp-listener.service (listener.py, go-sendxmpp --listen) → Queue: /var/lib/agent-codex/inbox/*.json → agent-codex-worker.service (worker.py, pfadgetriggert) → codex exec --sandbox read-only -C agents/ --add-dir ... (liest AGENTS.md → shared.md + agent.md; --add-dir macht die Platform-Repos aus AGENT_READABLE_DIRS sichtbar, aber weiterhin nur lesend -- gefunden 31.07.2026: vorher sah der Agent nur agents/ selbst und konnte "Untersuche den lokalen Checkout" gar nicht erfüllen) → Antwort per go-sendxmpp zurück an den Absender XMPP-Nachricht an builder@bot.0ff.space → agent-codex-builder-listener.service (dasselbe listener.py, eigene Queue: /var/lib/agent-codex/builder-inbox/*.json, eigene, engere AGENT_ALLOWED_SENDERS) → agent-codex-builder.service (builder.py, pfadgetriggert) → nach validiertem Job-Snapshot entweder codex exec --sandbox workspace-write -C agents/ --add-dir ... oder claude -p mit unveränderlicher Builder-Settings-Datei → Antwort + ggf. Commit/Push, immer nur an den Builder-Owner (lukas) Handoff vom Agent: worker.py erkennt einen ===BUILDER-HANDOFF===-Block in der Agent-Antwort, entfernt ihn aus der Nutzer-Antwort und legt den Inhalt selbst als Job in builder-inbox/ ab (kein XMPP-Umweg) -- `sender` im Job ist immer der Builder-Owner, `requested_by` der ursprüngliche Absender. ``` Jede Rolle läuft als eigenständiger systemd-Service (Listener + pfadgetriggerter Oneshot-Worker), gemeinsame Konfiguration über `commonEnvironment` in `../agent-codex.nix`. ## Persistenter Deploy-Runner Nach einem erfolgreichen direkten (nicht vom Board gesperrten) Commit schreibt `builder.py` einen deduplizierten, persistenten Auftrag nach `builder-deploy-inbox/` und beendet seinen Lauf. `agent-codex-deploy-runner.service` verarbeitet diesen Auftrag unabhängig vom Builder-Lebenszyklus und führt ausschließlich `nixos-rebuild switch` für `blunix` aus. Der Job-State (`building`, `committed`, `deploying`, `deployed` oder Fehlerstatus) liegt unter `builder-state/`. Ein gespeicherter Commit wird bei SIGTERM nicht erneut modelliert oder committed; Deployment-Wiederholungen sind begrenzt. Board-Completion wird für NixOS-Commits ausschließlich vom Runner genau einmal nach `deployed` oder `deploy_failed` geschrieben. Normale Board-Builder-Jobs und Board-Repair-Jobs verwenden denselben isolierten Worktree- und Runtime-Harness. Repair-Jobs ergänzen `pipeline_role="fixer"`, `agents/fixer.md` und ein strikt validiertes `FIX_RESULT` ohne `TARGET_PHASE`; sie sind ausschließlich für echte Fehler oder negative Reviews vorgesehen. Beide Board-Pfade setzen `defer_deploy_until_review`, sodass kein Board-Commit einen direkten Deploy-Auftrag erzeugt. Ausschließlich der Board-Worker darf nach einem positiven separaten Review des exakten Commits den bestehenden, auf `blunix` fest begrenzten Deploy-Runner beauftragen. ## Agent-Schreibfähigkeiten: Kalender und Aufgabenliste (seit 01.08.2026) Zwei eng begrenzte, direkte Schreibfähigkeiten für den sonst read-only Agent — kein Freigabe-Umweg über den Builder, beide Endpunkte tailnet-only: ``` Agent-Antwort enthält ===CALENDAR-EVENT===...===END-CALENDAR-EVENT=== → worker.py: extract_calendar_event() parst TITLE/START/END/DESCRIPTION → run_calendar_event() ruft calendar-add-event auf (reines Python + curl, läuft NIE innerhalb von Codex' eigener Sandbox -- read-only hat gar kein network_access-Feld) -- PUT gegen https://calendar.0ff.space/calendar/public (Radicale auf pinix, LDAP-Auth als bestehender `calendar`-Account) Agent-Antwort enthält ===TASK-EDIT===...===END-TASK-EDIT=== → worker.py: extract_task_edit() liest die Anweisung → run_task_edit_codex(): zweiter, eng begrenzter `codex exec --sandbox workspace-write --add-dir /home/drone/tasks` (nur dieses eine Verzeichnis, nie AGENT_READABLE_DIRS, kein network_access) -- editiert index.html/style.css im Aufgabenlisten-Repo (statisches HTML/CSS, kein Flake) → gleicher COMMIT-MESSAGE-Marker-Trick wie beim Builder (siehe unten), aber ohne dessen Freigabe-Gates: run_task_edit() committet/pusht direkt, sobald Codex den Block setzt ``` Beide Fähigkeiten leben komplett in `worker.py` (nicht `builder.py`) und haben keine eigenen State-Verzeichnisse -- kein Lock, kein Freigabe-Datensatz, keine Sitzungs-Fortführung für den Task-Edit-Lauf (jeder Lauf ist ein frischer, zustandsloser `codex exec`, die Repo-Dateien nach jedem Commit sind selbst die Kontinuität). Das Kalender-Ziel ist bewusst `calendar.0ff.space` (roher CalDAV-Port, tailnet-only über die interne Traefik-Route), nicht das öffentliche `cal.0ff.space`-Frontend, das strikt lesend bleibt (siehe `hosts/pinix/localServices/calendar/web.nix`). ## Codex-Sandbox-Eigenheiten (beide am 30.07.2026 live gefunden) Zwei nicht offensichtliche, harte Grenzen von Codex' eigener Sandbox, unabhängig von systemd-Sandboxing (`ProtectSystem`/`ReadWritePaths`): 1. **`.git/` ist unter `--sandbox workspace-write` immer schreibgeschützt**, auch wenn das Repo Teil von `--add-dir` ist. Codex kann also nie selbst committen/pushen. Deshalb macht `builder.py` das selbst, außerhalb von Codex' Sandbox: Codex markiert eine fertige Änderung mit `===COMMIT-MESSAGE===...===END-COMMIT-MESSAGE===`, das Skript extrahiert den Block, prüft mit `nix flake check` (nur wo `flake.nix` existiert) und committet/pusht erst danach selbst. 2. **Netzwerkzugriff ist unter `--sandbox workspace-write` standardmäßig aus** für Befehle, die Codex selbst ausführt (`git ls-remote`, `git fetch`, ...) -- unabhängig vom systemd-Netzwerk, das offen ist. Fix: `-c sandbox_workspace_write.network_access=true` beim `codex exec`-Aufruf in `builder.py`. Ohne das schlägt z. B. das Prüfen eines fremden PR-Branchs fehl, obwohl derselbe Befehl außerhalb der Sandbox (z. B. interaktiv als `drone`) klaglos funktioniert. Beide Male sah der Fehler zunächst nach einem echten Netzwerk-/ Infrastrukturproblem aus (Codex meldete selbst "Netzwerk-Sockets gesperrt"), war aber jeweils Codex' eigene Sandbox-Policy. Bei ähnlichen "kann nicht erreichen"-Meldungen aus einem Codex-Job zuerst hier nachsehen, bevor Zeit in Netzwerk-Debugging auf Infra-Ebene gesteckt wird. ## Rollen-Trennung (Sender-Allowlists) - **Agent**: `AGENT_ALLOWED_SENDERS = "@0ff.space"` -- Domain-Wildcard (neue Syntax in `listener.py`s `allowed_senders()`: ein Eintrag mit führendem `@` erlaubt jeden Absender auf dieser Domain). Antwortet jedem 0ff.space-Nutzer. - **Builder-Direktdialog**: eigener `AGENT_ALLOWED_SENDERS`-Override nur auf den Builder-Owner (`lukas@0ff.space`) für `agent-codex-builder-listener`. - **Handoff vom Agent**: erreicht den Builder immer, unabhängig davon, wer beim Agent die Konversation geführt hat (`requested_by` im Job). Antworten des Builders gehen aber immer an den Owner, nie an `requested_by`. ## Start-Freigabe (`/annehmen`, `/ablehnen`) Kommt eine Anfrage aus einem Handoff, dessen `requested_by` nicht der Owner ist, läuft sie nicht einfach autonom los -- Codex wird noch gar nicht aufgerufen: 1. `queue_for_start_approval()` legt einen Datensatz in `AGENT_BUILDER_START_PENDING_DIR` an (Job-ID, Absender, `requested_by`, Auftragstext und Runtime-Snapshot) und schickt dem Owner eine Zusammenfassung + Job-ID. 2. `/annehmen ` lädt den Datensatz, löscht ihn und führt den Auftrag erst jetzt aus (`run_and_commit()` -- derselbe Codex-Lauf, der auch für Direktdialog benutzt wird). 3. `/ablehnen ` löscht den Datensatz ersatzlos, ohne dass je ein Codex-Lauf stattfindet. Hintergrund (gefunden am 30.07.2026): vorher lief jeder Handoff sofort und unbeaufsichtigt los. Das fiel auf, als ein `nixos-rebuild switch` für eine unabhängige Änderung `agent-codex-builder.service` neu gestartet hat, während gerade ein Handoff (neuer Dex-OIDC-Client) mitten in der Bearbeitung lief -- systemd hat den Prozess per SIGTERM gekillt, der Job blieb tot in `builder-processing/` liegen, und im Arbeitsbaum standen danach unvollständige, uncommittete Änderungen (Dex-Client-Eintrag ohne das zugehörige `secrets/oidcTrek.age`, das nie erzeugt wurde). Nichts wurde fälschlich committed -- die Freigabe für den Commit selbst hätte das ohnehin verhindert --, aber der Owner erfuhr erst durch manuelles Nachschauen davon. Die Start-Freigabe oben macht Handoffs sichtbar, *bevor* Ressourcen dafür verbraucht werden; das eigentliche Kill-Problem behebt der SIGTERM-Handler unten. ## Freigabe-Workflow (`/freigeben`, `/verwerfen`) Kommt eine fertige Änderung aus einem Handoff, dessen `requested_by` nicht der Owner ist, committet der Builder auch nach der Start-Freigabe oben nicht sofort: 1. `queue_for_approval()` stasht die Änderung (`git stash push --include-untracked -m builder-pending-`) und legt einen Datensatz in `AGENT_BUILDER_PENDING_DIR` an. 2. Der Owner bekommt die Zusammenfassung + Job-ID als Antwort. 3. `/freigeben ` popt den Stash, prüft (`nix flake check` wo zutreffend) und committet/pusht wie gewohnt. 4. `/verwerfen ` droppt den Stash ersatzlos. `/neu` verwirft für den Absender den bisherigen Gesprächsstand -- unabhängig von beiden Freigabe-Mechanismen. Beim Agent (worker.py) heißt das: die resumte Codex-Sitzung wird verworfen. Beim Builder gibt es seit `docs/intent/builder-iterationslimit.md` keine resumte Sitzung mehr -- `/neu` löscht dort den gespeicherten Zwischenstand-Text, der sonst automatisch in den nächsten Auftrag einfließt (siehe „Atomare Sessions und Iterationslimit" unten). ## Atomare Sessions und Iterationslimit (Builder) Seit `docs/intent/builder-iterationslimit.md` läuft jeder Builder-Auftrag als eigenständige, neue Modell-Session. Board-Jobs tragen dafür den beim Start gewählten Snapshot `codex` oder `claude`; ein Wechsel im Board ändert keine laufende Pipeline. Beide Launcher übergeben nie eine frühere Session (anders als der Agent in `worker.py`, der weiterhin Codex-Sitzungen resumt). Statt einer laufenden Sitzung wird die zuletzt an den Absender gesendete Antwort in `agent.db`s `context`-Tabelle (`AGENT_DB_PATH`, getrennt von der `sessions`-Tabelle des Agent) unter `role="builder"` gespeichert und dem nächsten Auftrag desselben Absenders als klar markierter Kontext-Block vorangestellt -- `/neu` löscht diesen Eintrag ersatzlos. Zusätzlich zum Wall-Clock-Timeout zählen die runtime-spezifischen Streaming- Schleifen die jeweiligen Codex- beziehungsweise Claude-Ereignisse. Für Codex zählt `common.run_streaming()` jedes `item.completed`-Event aus Codex' `--json`-Ausgabe live mit (per Popen + Reader-Thread statt `subprocess.run(capture_output=True)`, damit während des Laufs gezählt werden kann) und terminiert den Prozess, sobald `AGENT_BUILDER_ITERATION_LIMIT` (Default 20) überschritten wird. Live gegen `codex-cli` verifiziert: auch ein Turn mit mehreren Tool-Aufrufen bleibt ein einziges `turn.started`/`turn.completed`-Paar -- `item.completed` ist die feinste beobachtbare Granularität, deshalb zählt hier ein Ereignis als eine Iteration, unabhängig vom Item-Typ. Bei Limit-Erreichen baut `builder.build_interim_report()` einen automatisch generierten Zwischenstandsbericht aus den bis dahin eingegangenen `agent_message`-Texten (Codex selbst kann nach dem harten Abbruch keinen eigenen Abschlussbericht mehr liefern). Dieser Pfad überspringt `extract_commit_message()`/`apply_commit()`/ `queue_for_approval()` vollständig -- kein Commit, kein Push, kein Reviewer-Lauf -- selbst wenn der eingefangene Teiltext zufällig wie ein `===COMMIT-MESSAGE===`-Block aussieht. ## Unterbrechung durch Neustart (SIGTERM) `main()` installiert einen `SIGTERM`-Handler (`_handle_sigterm()`), der bei jedem Neustart des Service -- z. B. weil ein `nixos-rebuild switch` `agent-codex-builder.service` neu lädt -- greift, während gerade ein Job läuft: `requeue_interrupted_job()` verschiebt die Job-Datei zurück von `builder-processing/` nach `AGENT_BUILDER_QUEUE_DIR` (der pfadgetriggerte Service greift sie beim nächsten Lauf automatisch wieder auf) und schickt dem Absender eine Notiz, dass der Auftrag unterbrochen wurde und ggf. schon halb angewendete Dateiänderungen im Arbeitsbaum stehen. Das verhindert nicht, dass Codex mitten in einer Bearbeitung getötet wird (das kann jederzeit wieder passieren -- jeder Neustart des Service während eines aktiven Jobs killt ihn), macht das Ergebnis aber sichtbar und automatisch wiederholbar statt eines stillen, für immer verwaisten Eintrags in `builder-processing/`. Deploys, die diesen Service berühren, sollten daher idealerweise prüfen, ob gerade ein Job läuft (`systemctl is-active agent-codex-builder.service`), bevor sie switchen -- das ist aktuell noch kein automatisierter Check, nur eine manuelle Vorsichtsmaßnahme. ## Mehrere Schreibverzeichnisse `AGENT_BUILDER_WRITABLE_DIRS` ist doppelpunkt-getrennt (aktuell `/home/drone/nixos-config:/home/drone/website:/home/drone/dashboard:/home/drone/launcher:/home/drone/calendar-web`). Jedes Verzeichnis bekommt sein eigenes `--add-dir` und `ReadWritePaths`; `apply_commit()` iteriert über alle, die tatsächlich Änderungen haben, und prüft `nix flake check` nur dort, wo `flake.nix` existiert (nixos-config ja, die anderen vier nein). `commit_and_push()` pusht dabei auf den jeweils aktuell ausgecheckten Branch (`git rev-parse --abbrev-ref HEAD`), nicht hartcodiert auf `dev` — `dashboard` und `launcher` haben nur `main`, und das ist auch, was deren Auto-Pull-Webhooks tatsächlich ziehen (siehe `hosts/blunix/localServices/webhook.nix` bzw. `hosts/pinix/localServices/webhook.nix`). ## State-Verzeichnisse (`/var/lib/agent-codex/`) | Pfad | Zweck | | --- | --- | | `inbox/`, `processing/`, `failed/` | Agent-Job-Queue | | `builder-inbox/`, `builder-processing/`, `builder-failed/` | Builder-Job-Queue (Handoffs + Direktdialog) | | `builder-start-pending/` | Start-Freigabe-Datensätze zu offenen `/annehmen`/`/ablehnen`-Vorgängen (vor jedem Codex-Lauf) | | `builder-pending/` | Freigabe-Datensätze zu offenen `/freigeben`-Vorgängen (nach getaner Arbeit, vor dem Commit) | | `sessions/`, `builder-sessions/` | Codex-Sitzungs-IDs pro Absender, getrennt pro Rolle (keine Kollision bei gleichem Absender) | | `senders/`, `builder-senders/` | Marker, ob der Erstkontakt-Hinweis schon geschickt wurde | | `codex-home/` | `CODEX_HOME` (Auth, Sitzungs-Rollouts unter `sessions/JJJJ/MM/TT/`) | ## Tests `test_agent_codex.py` -- lädt `listener.py`/`worker.py`/`builder.py` direkt (kein Package), reine `unittest`. Stash/Approval-Tests laufen gegen echte temporäre Git-Repos, nicht nur Mocks. Lokal: ```bash nix-shell -p python3 --run \ "cd hosts/blunix/localServices/agent-codex && python3 -m unittest test_agent_codex.py -v" ```