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):
.git/ist unter--sandbox workspace-writeimmer schreibgeschützt, auch wenn das Repo Teil von--add-dirist. Codex kann also nie selbst committen/pushen. Deshalb machtbuilder.pydas selbst, außerhalb von Codex' Sandbox: Codex markiert eine fertige Änderung mit===COMMIT-MESSAGE===...===END-COMMIT-MESSAGE===, das Skript extrahiert den Block, prüft mitnix flake check(nur woflake.nixexistiert) und committet/pusht erst danach selbst.- Netzwerkzugriff ist unter
--sandbox workspace-writestandardmäß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=truebeimcodex exec-Aufruf inbuilder.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 alsdrone) 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 inlistener.pysallowed_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üragent-codex-builder-listener. - Handoff vom Agent: erreicht den Builder immer, unabhängig davon,
wer beim Agent die Konversation geführt hat (
requested_byim Job). Antworten des Builders gehen aber immer an den Owner, nie anrequested_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:
queue_for_start_approval()legt einen Datensatz inAGENT_BUILDER_START_PENDING_DIRan (Job-ID, Absender,requested_by, Auftragstext und Runtime-Snapshot) und schickt dem Owner eine Zusammenfassung + Job-ID./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)./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:
queue_for_approval()stasht die Änderung (git stash push --include-untracked -m builder-pending-<job_id>) und legt einen Datensatz inAGENT_BUILDER_PENDING_DIRan.- Der Owner bekommt die Zusammenfassung + Job-ID als Antwort.
/freigeben <id>popt den Stash, prüft (nix flake checkwo zutreffend) und committet/pusht wie gewohnt./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"