This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/blunix/localServices/agent-codex/README.md
2026-09-10 15:52:54 +02:00

15 KiB

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 <repo> ...
        (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 <repo> ...
        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.pys 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 <id> 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 <id> 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-<job_id>) und legt einen Datensatz in AGENT_BUILDER_PENDING_DIR an.
  2. Der Owner bekommt die Zusammenfassung + Job-ID als Antwort.
  3. /freigeben <id> popt den Stash, prüft (nix flake check wo zutreffend) und committet/pusht wie gewohnt.
  4. /verwerfen <id> 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.dbs 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:

nix-shell -p python3 --run \
  "cd hosts/blunix/localServices/agent-codex && python3 -m unittest test_agent_codex.py -v"