diff --git a/README.md b/README.md
index 849821d..8d950a0 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,25 @@ preamble = "set -a; . ./.env; set +a" # shell run before launch (e.g. l
active_re = "esc interrupt|thinking|running tool|preparing patch"
limit_re = "usage limit|limit reached"
+[backend.codex] # Codex TUI, exposed through Codex Remote Control
+bin = "codex"
+preamble = "{bin} remote-control start --json >/dev/null"
+remote_addr = "unix://" # connect the TUI to that daemon (one shared thread writer)
+flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-alt-screen"
+supports_resume = false # thread-ID persistence is not implemented by this harness
+prompt_delivery = "arg"
+process_name = "codex"
+footer_ui = true
+log_grace = 180
+submit_key = "Enter"
+startup_prompt_re = "Trusting the directory|Do you trust"
+startup_prompt_response = "1" # explicitly trust the agent's configured working directory
+startup_prompt_delay = 2
+stall_idle = 300
+active_re = "esc to interrupt|working|thinking|running|searching|exploring|implementing"
+limit_re = "usage limit|limit reached|reached your .*limit|out of (credits|tokens)"
+fatal_re = "connection is errored|not logged in|authentication required"
+
[backend.demo] # a dependency-free backend for testing the harness mechanics
bin = "echo '[demo] {session} up'; exec sleep 1000000"
prompt_delivery = "exec" # {kickoff}=prompt file, {session}=session name, {model}=model
@@ -185,8 +204,20 @@ prompt_delivery = "exec" # {kickoff}=prompt file, {session}=session name,
For an `"arg"` backend the flag *templates* are configurable (so you can point at a non-claude
CLI): `resume_flag` (default `--resume '{id}'`), `model_flag` (default `--model '{model}'`),
-`remote_control_flag` (default `--remote-control '{session}'`). A backend that sets `process_name`
+`remote_control_flag` (default `--remote-control '{session}'`), `remote_addr_flag` (default
+`--remote '{addr}'`), and `remote_cwd_flag` (default `-C {dir}` whenever `remote_addr` is set).
+A backend that sets `process_name`
participates in backend-mismatch healing; one that doesn't (e.g. `demo`) never does.
+An optional `preamble` runs first and must succeed before the TUI starts; it supports `{bin}`,
+`{session}`, `{model}`, and `{dir}` templates. The Codex backend uses it to idempotently start the
+shared Remote Control daemon, then uses `remote_addr = "unix://"` to connect each tmux-hosted Codex
+TUI to that daemon. The accompanying `-C` is required because the daemon's process directory is
+independent of the agent's. This keeps the app-server as the sole thread-store writer while local
+and Remote clients share the session in the configured project directory.
+For an unattended TUI with a deterministic first-run gate, `startup_prompt_re` opts into one
+screen check after `startup_prompt_delay` seconds; on a match, the harness types the explicitly
+configured `startup_prompt_response` and `submit_key`. Codex uses this to trust the agent's
+configured project directory so its argv kickoff can proceed.
### `[[agent]]` — one block per agent
@@ -476,7 +507,7 @@ nix develop -c python3 agents.py selftest # or run one command in it
nix flake check # evaluate + build the devShell
```
-The agent CLIs themselves (`claude`, `opencode`) are **external, non-Nix tools** — install them
+The agent CLIs themselves (`claude`, `codex`, `opencode`) are **external, non-Nix tools** — install them
per their own docs and make sure they are on `PATH` before launching live agents. The devShell
documents this in its banner.
@@ -510,13 +541,15 @@ What it runs:
parsing, `WAITING-UNTIL` / stall parsing, and the per-backend activity detectors (claude +
opencode footers). Always run; a failure fails the suite. Run them alone with
`python3 -m unittest discover -s tests` (or `python3 tests/test_unit.py`).
-- **Live backend smokes** (`tests/smoke_claude.sh`, `tests/smoke_opencode.sh`) — each brings a
+- **Live backend smokes** (`tests/smoke_claude.sh`, `tests/smoke_codex.sh`,
+ `tests/smoke_opencode.sh`) — each brings a
throwaway scratch project up **through `agents.py`** on a real backend, in a fully isolated
sandbox (its own unique `session_prefix`, a temp `log_dir`, and — for opencode — a dedicated
server on a non-default port `AOTEST_OC_PORT`, default `4097`), confirms the session attaches and
`status` reports it RUNNING, then `down`s it and cleans up (no leftover sessions, port freed).
Each **SKIPs gracefully** (exit 0) when its backend's binary or creds are unavailable. Useful env:
- `CLAUDE_BIN` / `OPENCODE_BIN`, `AOTEST_MODEL`, `AOTEST_OC_PORT`, `AOTEST_OC_CREDS`.
+ `CLAUDE_BIN` / `CODEX_BIN` / `OPENCODE_BIN`, `AOTEST_MODEL`, `AOTEST_OC_PORT`,
+ `AOTEST_OC_CREDS`.
- **Isolation sanity** — after the live runs, the runner asserts no `aotest-*` tmux sessions leaked
and reports that any live sessions are untouched.
diff --git a/agents.example.toml b/agents.example.toml
index c0af01e..aede060 100644
--- a/agents.example.toml
+++ b/agents.example.toml
@@ -6,7 +6,7 @@
#
# This example is self-contained: its agents use a dependency-free `demo` backend (a shell that
# just idles), so the whole project can be brought up and torn down with no external agent CLI —
-# see ./smoke.sh. The `claude` and `opencode` backends below are the real ones; point an agent at
+# see ./smoke.sh. The `claude`, `codex`, and `opencode` backends below are real; point an agent at
# them with `backend = "claude"` for a live run.
# ─────────────────────────── global watchdog cadence ───────────────────────────
@@ -42,11 +42,30 @@ supports_resume = true
prompt_delivery = "arg"
process_name = "claude" # used for backend-mismatch healing
submit_key = "Enter"
+startup_prompt_re = "Trusting the directory|Do you trust"
+startup_prompt_response = "1"
+startup_prompt_delay = 2
stall_idle = 300
active_re = "esc to interrupt|Running tool|⠇|⠙|· \\d+"
limit_re = "spend limit|usage limit|limit reached|reached your .*limit|out of (credits|tokens)"
fatal_re = "redacted_thinking|blocks cannot be modified|cannot be modified"
+[backend.codex] # Codex TUI + the shared Remote Control app-server daemon
+bin = "codex"
+preamble = "{bin} remote-control start --json >/dev/null"
+remote_addr = "unix://" # make the TUI a client of the daemon; it must not own a second writer
+flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-alt-screen"
+supports_resume = false # the harness does not yet persist Codex thread IDs
+prompt_delivery = "arg"
+process_name = "codex"
+footer_ui = true
+log_grace = 180
+submit_key = "Enter"
+stall_idle = 300
+active_re = "esc to interrupt|working|thinking|running|searching|exploring|implementing"
+limit_re = "usage limit|limit reached|reached your .*limit|out of (credits|tokens)"
+fatal_re = "connection is errored|not logged in|authentication required"
+
[backend.opencode] # the real opencode backend (a TUI; prompt typed after connect)
bin = "opencode"
attach = "{bin} attach {server} --dir {dir}"
diff --git a/agents.py b/agents.py
index 54d74d8..cb30c47 100755
--- a/agents.py
+++ b/agents.py
@@ -349,15 +349,45 @@ def start_agent(cfg, agent, *, force=False):
if backend.get("remote_control"):
parts.append(_render_template(backend.get("remote_control_flag",
"--remote-control '{session}'"), {"session": session}))
+ if backend.get("remote_addr"):
+ remote_values = {
+ "addr": backend["remote_addr"], "session": session, "model": model,
+ "dir": shlex.quote(cwd),
+ }
+ parts.append(_render_template(
+ backend.get("remote_addr_flag", "--remote '{addr}'"), remote_values))
+ remote_cwd_flag = backend.get("remote_cwd_flag", "-C {dir}")
+ if remote_cwd_flag:
+ parts.append(_render_template(remote_cwd_flag, remote_values))
if model:
parts.append(_render_template(backend.get("model_flag", "--model '{model}'"), {"model": model}))
if backend.get("flags"):
parts.append(backend["flags"])
parts.append(f"\"$(cat '{kf}')\"")
cmd = " ".join(p for p in parts if p)
+ # Some interactive CLIs need an idempotent service prepared before their TUI starts.
+ # The Codex backend starts the shared app-server here, while remote_addr above makes the
+ # pane's TUI a client of that daemon so both local and Remote UIs share one thread writer.
+ if backend.get("preamble"):
+ preamble = _render_template(backend["preamble"], {
+ "bin": backend["bin"], "session": session, "model": model,
+ "dir": shlex.quote(cwd),
+ })
+ cmd = f"{preamble} && {cmd}"
log(f"starting {session} ({agent['backend']}, kind={agent['kind']}, phase={pid}, "
f"model={model or 'default'}{', resume' if rid else ''})")
new_session(session, cwd, cmd, log_path)
+ # An unattended TUI may present a deterministic first-run gate before it processes the
+ # prompt already supplied on argv (Codex asks whether to trust a new project directory).
+ # Backends opt in with both the screen regex and response; nothing is accepted implicitly.
+ startup_re = backend.get("startup_prompt_re")
+ if startup_re:
+ time.sleep(int(backend.get("startup_prompt_delay", 2)))
+ if re.search(startup_re, capture_pane(session, 40), re.I):
+ _run(["tmux", "send-keys", "-t", TP(session), "-l", "--",
+ str(backend.get("startup_prompt_response", ""))])
+ _run(["tmux", "send-keys", "-t", TP(session),
+ backend.get("submit_key", "Enter")])
def start_service(cfg, svc):
session = svc["session"]
diff --git a/flake.nix b/flake.nix
index 210a693..50aabf1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -13,7 +13,7 @@
{
# Reproducible devShell with the harness runtime deps. The driver itself is pure Python
# stdlib (it needs tomllib, so python >= 3.11); the rest is what the agents/watchdog shell
- # out to. Make the agent CLIs (claude / opencode) available on PATH separately — they are
+ # out to. Make the agent CLIs (claude / codex / opencode) available on PATH separately — they are
# external, non-Nix tools; install them per their own docs, then `nix develop` here.
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
diff --git a/skills/codeberg-pages/SKILL.md b/skills/codeberg-pages/SKILL.md
new file mode 100644
index 0000000..0909231
--- /dev/null
+++ b/skills/codeberg-pages/SKILL.md
@@ -0,0 +1,138 @@
+---
+name: codeberg-pages
+description: >-
+ Publish a static site to Codeberg Pages, including custom domains on the new
+ "git-pages" server. Use when deploying a site to Codeberg Pages, setting up or
+ debugging a codeberg.page / custom-domain deployment, wiring the DNS records
+ (A/AAAA, CNAME), the _git-pages-repository TXT authorization record, or the
+ deploy webhook — or when a custom domain serves a TLS error / never gets a
+ certificate. Covers the 2025→2026 migration off the old Pages Server v2
+ (.domains file) to git-pages (webhook + TXT authorization).
+---
+
+# Publishing a site to Codeberg Pages
+
+Codeberg Pages migrated from the old **Pages Server v2** (automatic deploy,
+`.domains` file) to the new **git-pages** server. On git-pages a deployment is
+**webhook-triggered** and a custom domain is authorized by a **TXT record**, not
+by a file in the repo. If you're following older docs or a `.domains`-based
+`deploy.sh`, that's why things silently don't work.
+
+> All values below were verified against the official docs
+> ( and `.../using-custom-domain/`).
+> Codeberg changes these; re-check the docs if something behaves unexpectedly.
+
+## The mental model
+
+1. Static site content lives on a branch named **`pages`** (per-repo site) — push
+ your built site there.
+2. On git-pages, pushing alone does **not** deploy. A **webhook** on the repo,
+ pointed at the domain you want, is what triggers a deployment.
+3. A custom domain is bound to the repo by a **TXT authorization record**
+ (`_git-pages-repository.`) plus the normal A/AAAA/CNAME records.
+4. TLS (Let's Encrypt) is issued **only after the first successful deployment**.
+ Before that, browsers show a TLS error — that is expected, not a bug.
+
+## Basic deploy (no custom domain, `*.codeberg.page`)
+
+- Put the site on a `pages` branch and push it.
+- Add a webhook: repo **Settings → Webhooks → Forgejo**, Target URL
+ `https://.codeberg.page//`, **Branch filter: `pages`**.
+- (User/org site: name the repo `pages` and use Target URL
+ `https://.codeberg.page/`.)
+
+## Custom domain setup (git-pages)
+
+Do all four. Missing #2 or #3 is the usual cause of "DNS looks right but the site
+won't serve / no certificate."
+
+### 1. DNS: point the domain at Codeberg
+
+Exact values (verify against the docs — Codeberg has changed IPs before):
+
+- **Apex domain** (`example.org`):
+ - `A` → `217.197.84.141`
+ - `AAAA` → `2a0a:4580:103f:c0de::2`
+- **Subdomain** (`www.example.org`, `foo.example.org`):
+ - `CNAME` → `codeberg.page.` ← **note the trailing dot.**
+
+**Trailing-dot trap:** in a zone file / most DNS UIs, a CNAME target *without* a
+trailing dot is treated as relative and the zone is appended — e.g. entering
+`codeberg.page` (or an old `.codeberg.page`) can resolve to
+`codeberg.page.example.org.`, which is broken. Always use the fully-qualified
+`codeberg.page.` with the dot. (ALIAS/ANAME works where CNAME isn't allowed, but
+conflicts with DNSSEC-signed zones.)
+
+### 2. TXT authorization record (this is how git-pages maps domain → repo)
+
+Create one **per domain** you serve:
+
+```
+_git-pages-repository.example.org. TXT "https://codeberg.org//.git"
+```
+
+- Name: the `_git-pages-repository.` prefix on the exact domain (including each
+ subdomain you serve — apex and `www` each need their own if both are used).
+- Value: the **HTTPS clone URL** of the repo, ending in `.git`.
+- (If you deploy via **Forgejo Actions** instead of a webhook, the record is
+ `_git-pages-forge-allowlist.` with the same clone-URL value.)
+
+### 3. Deploy webhook (per domain)
+
+Repo **Settings → Webhooks → Forgejo**:
+
+- **Target URL:** the domain itself, and **`http://` (not `https://`) for the
+ first deployment** — this is documented, not a mistake (the cert doesn't exist
+ yet). One webhook per domain, e.g. `http://example.org`, `http://foo.example.org`.
+- **Branch filter:** `pages`.
+- After the first successful deploy and cert issuance, you may switch the Target
+ URLs to `https://`.
+
+### 4. Trigger the first deploy
+
+**Push to the `pages` branch** (re-run your deploy script / `git push origin pages`).
+The push fires the webhook, git-pages pulls and deploys, then requests a
+Let's Encrypt certificate.
+
+- **Do NOT rely on the webhook "Test delivery" button** — the official docs say it
+ fails by design and is not a valid way to verify or trigger a deploy. Verify by
+ pushing and then checking the webhook's recent-deliveries log, or just load the
+ site. (This corrects a common misconception that "Test delivery" triggers a deploy.)
+
+## The `.domains` file is obsolete
+
+Under the old Pages Server v2, a `.domains` file in the branch listed the domains
+and did apex-vs-alias redirects. On git-pages it is **no longer used** — authorization
+comes from the TXT record. It's harmless to leave, but you can delete it (and drop any
+`.domains` handling from `deploy.sh`). Bonus: on git-pages each domain gets its **own**
+deployment, so a second domain serves the site directly instead of 301-redirecting to
+the primary as the old `.domains` system did.
+
+## TLS / certificate notes
+
+- A cert is issued **only after the first successful webhook deployment**. A TLS
+ error before that is expected.
+- If the domain has **CAA records**, they must allow Let's Encrypt (including the
+ staging issuer) or the cert request is refused.
+- Cert still never issues after a successful deploy → confirm the `_git-pages-repository`
+ TXT value exactly matches the repo's HTTPS `.git` URL, and that the webhook Target
+ URL matches the domain.
+
+## Quick troubleshooting checklist
+
+- Browser TLS error, no cert → no successful deploy yet. Check webhook deliveries;
+ push to `pages`; confirm webhook Target URL used `http://` for the first deploy.
+- "DNS is correct but site won't serve" → missing `_git-pages-repository` TXT, or
+ missing/mis-branch-filtered webhook.
+- CNAME resolves to `codeberg.page.` → missing trailing dot; set target to
+ `codeberg.page.`.
+- CAA present → ensure Let's Encrypt is allowed.
+- Old `.domains` behavior expected (redirects) → gone on git-pages; each domain now
+ deploys independently.
+
+## Sources
+
+- Codeberg Pages:
+- Using custom domains:
+- pages-server (now in maintenance, superseded by git-pages):
+
diff --git a/tests/run.sh b/tests/run.sh
index 560b0c8..c347c52 100755
--- a/tests/run.sh
+++ b/tests/run.sh
@@ -4,7 +4,8 @@
#
# • UNIT tests — always run (pure logic, no agents spawned). A failure fails the suite.
# • CLAUDE smoke — live, run when the `claude` CLI is available; SKIPs otherwise.
-# • OPENCODE smoke — live, run when `opencode` + creds are available; SKIPs otherwise.
+# • CODEX smoke — live, run when Codex is installed, logged in, and Remote Control connects.
+# • OPENCODE smoke — live, run when `opencode` + creds are available; SKIPs otherwise.
# • ISOLATION sanity — after the live runs: assert no leftover aotest-* tmux sessions, and that
# the live cc-ci-* sessions are untouched.
#
@@ -19,7 +20,7 @@ set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/.." && pwd)"
RC=0
-UNIT=FAIL CLAUDE=SKIP OPENCODE=SKIP ISO=PASS
+UNIT=FAIL CLAUDE=SKIP CODEX=SKIP OPENCODE=SKIP ISO=PASS
echo "######################################################################"
echo "# agent-orchestrator test suite"
@@ -47,6 +48,7 @@ run_smoke() {
# ── live smoke tests (when backends available) ──────────────────────────────────────
run_smoke "CLAUDE" "$HERE/smoke_claude.sh"; case $? in 0) CLAUDE=PASS;; 2) CLAUDE=SKIP;; *) CLAUDE=FAIL; RC=1;; esac
+run_smoke "CODEX" "$HERE/smoke_codex.sh"; case $? in 0) CODEX=PASS;; 2) CODEX=SKIP;; *) CODEX=FAIL; RC=1;; esac
run_smoke "OPENCODE" "$HERE/smoke_opencode.sh"; case $? in 0) OPENCODE=PASS;; 2) OPENCODE=SKIP;; *) OPENCODE=FAIL; RC=1;; esac
# ── isolation sanity ────────────────────────────────────────────────────────────────
@@ -69,7 +71,7 @@ fi
# ── summary ─────────────────────────────────────────────────────────────────────────
echo; echo "######################################################################"
-echo "# SUMMARY: unit=$UNIT claude=$CLAUDE opencode=$OPENCODE isolation=$ISO"
+echo "# SUMMARY: unit=$UNIT claude=$CLAUDE codex=$CODEX opencode=$OPENCODE isolation=$ISO"
echo "######################################################################"
[ "$RC" -eq 0 ] && echo "ALL RUN TESTS PASSED (skips are OK)" || echo "SUITE FAILED"
exit "$RC"
diff --git a/tests/smoke_codex.sh b/tests/smoke_codex.sh
new file mode 100755
index 0000000..27a74aa
--- /dev/null
+++ b/tests/smoke_codex.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# Isolated live smoke of the Codex backend, driven entirely through agents.py.
+# It starts one uniquely-named tmux session, verifies Remote Control and a daemon-connected Codex
+# TUI, then removes only that session. The shared Remote Control daemon intentionally remains
+# available.
+set -uo pipefail
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/.." && pwd)"
+CODEX_BIN="${CODEX_BIN:-$(command -v codex 2>/dev/null || echo "$HOME/.local/bin/codex")}"
+MODEL="${AOTEST_CODEX_MODEL:-}"
+PREFIX="aotest-x-$$-"
+SANDBOX="$(mktemp -d)"
+CFG="$SANDBOX/agents.toml"
+FAILED=0
+
+pass(){ echo " PASS: $*"; }
+fail(){ echo " FAIL: $*"; FAILED=1; }
+
+cleanup(){
+ local rc=$?
+ python3 "$REPO/agents.py" --config "$CFG" down probe >/dev/null 2>&1 || true
+ if command -v tmux >/dev/null 2>&1; then
+ tmux ls 2>/dev/null | sed 's/:.*//' | grep "^${PREFIX}" | while read -r s; do
+ tmux kill-session -t "=$s" 2>/dev/null || true
+ done || true
+ fi
+ rm -rf "$SANDBOX"
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+echo "=== codex backend smoke (isolated: prefix=${PREFIX}) ==="
+command -v tmux >/dev/null 2>&1 || { echo "SKIP: tmux not on PATH"; exit 0; }
+[ -x "$CODEX_BIN" ] || command -v "$CODEX_BIN" >/dev/null 2>&1 \
+ || { echo "SKIP: codex binary not found ($CODEX_BIN)"; exit 0; }
+"$CODEX_BIN" login status >/dev/null 2>&1 \
+ || { echo "SKIP: Codex is not logged in"; exit 0; }
+remote_status="$("$CODEX_BIN" remote-control start --json 2>&1)"
+echo "$remote_status" | grep -q '"status":"connected"' \
+ || { echo "SKIP: Codex Remote Control is not connected: $remote_status"; exit 0; }
+pass "Codex Remote Control reports connected"
+
+model_line=""
+[ -n "$MODEL" ] && model_line="model = \"$MODEL\""
+cat > "$CFG" </dev/null"
+remote_addr = "unix://"
+flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-alt-screen"
+supports_resume = false
+prompt_delivery = "arg"
+process_name = "codex"
+footer_ui = true
+log_grace = 180
+submit_key = "Enter"
+startup_prompt_re = "Trusting the directory|Do you trust"
+startup_prompt_response = "1"
+startup_prompt_delay = 2
+stall_idle = 300
+active_re = "esc to interrupt|working|thinking|running|searching"
+limit_re = "usage limit|limit reached"
+
+[[agent]]
+name = "probe"
+kind = "persistent"
+prompt = "You are a harness self-test. Reply exactly CODEX_BACKEND_READY and then wait silently. Do not use tools or modify files."
+EOF
+
+python3 "$REPO/agents.py" --config "$CFG" up probe \
+ || { fail "agents.py up probe errored"; echo "=== CODEX BACKEND SMOKE: FAIL ==="; exit 1; }
+
+sleep 12
+if tmux has-session -t "=${PREFIX}probe" 2>/dev/null; then
+ cmd="$(tmux display-message -p -t "=${PREFIX}probe:" '#{pane_current_command}')"
+ pass "session ${PREFIX}probe created via agents.py (pane command: ${cmd})"
+else
+ fail "${PREFIX}probe session was not created"
+ echo "=== CODEX BACKEND SMOKE: FAIL ==="
+ exit 1
+fi
+
+pane="$(tmux capture-pane -p -t "=${PREFIX}probe:" -S -200 2>/dev/null)"
+if [ "$cmd" = "codex" ] && echo "$pane" | grep -q "CODEX_BACKEND_READY"; then
+ pass "Codex TUI attached and completed the probe"
+else
+ fail "Codex probe did not complete (cmd=${cmd}); tail: $(echo "$pane" | grep -vE '^\s*$' | tail -5)"
+fi
+
+if echo "$pane" | grep -Fq "$REPO"; then
+ pass "daemon-created Codex thread uses the requested project directory"
+else
+ fail "Codex thread did not use requested directory $REPO"
+fi
+
+if python3 "$REPO/agents.py" --config "$CFG" status \
+ | grep -E '^\s*probe\b' | grep -q RUNNING; then
+ pass "agents.py status reports probe RUNNING"
+else
+ fail "agents.py status did not report probe RUNNING"
+fi
+
+python3 "$REPO/agents.py" --config "$CFG" down probe >/dev/null 2>&1
+sleep 2
+if tmux has-session -t "=${PREFIX}probe" 2>/dev/null; then
+ fail "${PREFIX}probe still alive after agents.py down"
+else
+ pass "agents.py down cleanly removed the session"
+fi
+
+if [ "$FAILED" = 0 ]; then echo "=== CODEX BACKEND SMOKE: PASS ==="; exit 0
+else echo "=== CODEX BACKEND SMOKE: FAIL ==="; exit 1; fi
diff --git a/tests/test_unit.py b/tests/test_unit.py
index c0e10c0..e0262fc 100755
--- a/tests/test_unit.py
+++ b/tests/test_unit.py
@@ -74,6 +74,23 @@ stall_idle = 900
active_re = "esc interrupt|thinking|inferring|running tool|tool call|preparing patch|reading|searching"
limit_re = "usage limit|limit reached"
+[backend.codex]
+bin = "codex"
+preamble = "{bin} remote-control start --json >/dev/null"
+remote_addr = "unix://"
+flags = "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-alt-screen"
+supports_resume = false
+prompt_delivery = "arg"
+process_name = "codex"
+footer_ui = true
+log_grace = 180
+submit_key = "Enter"
+startup_prompt_re = "Trusting the directory|Do you trust"
+startup_prompt_response = "1"
+startup_prompt_delay = 0
+active_re = "working|thinking|running"
+limit_re = "usage limit|limit reached"
+
[backend.demo]
bin = "echo up; exec sleep 100000"
prompt_delivery = "exec"
@@ -102,6 +119,13 @@ kind = "persistent"
backend = "opencode"
prompt = "hi"
+[[agent]]
+name = "cx"
+kind = "persistent"
+backend = "codex"
+model = "gpt-test"
+prompt = "hi from codex"
+
[[agent]]
name = "custom"
kind = "persistent"
@@ -233,10 +257,42 @@ class TestExampleConfig(unittest.TestCase):
cfg = agents.load_config(ex)
self.assertIn("builder", cfg["agents"])
self.assertIn("adversary", cfg["agents"])
- for be in ("demo", "claude", "opencode"):
+ for be in ("demo", "claude", "codex", "opencode"):
self.assertIn(be, cfg["backends"], f"backend {be} missing from example")
self.assertEqual(len(agents.phases(cfg)), 2)
+ def test_codex_launch_prepares_remote_control_then_connects_tui_to_daemon(self):
+ tmp = tempfile.mkdtemp(prefix="aotest-ut-codex-")
+ old_alive, old_new, old_log = agents.session_alive, agents.new_session, agents.log
+ old_capture, old_run = agents.capture_pane, agents._run
+ try:
+ cfg = agents.load_config(_make_project(tmp))
+ launched = []
+ sent = []
+ agents.session_alive = lambda _session: False
+ agents.new_session = lambda session, cwd, cmd, log_path: launched.append(cmd)
+ agents.log = lambda _msg: None
+ agents.capture_pane = lambda *_args, **_kwargs: "Trusting the directory"
+ agents._run = lambda command: sent.append(command)
+ agents.start_agent(cfg, cfg["agents"]["cx"])
+ self.assertEqual(len(launched), 1)
+ cmd = launched[0]
+ self.assertTrue(cmd.startswith(
+ "codex remote-control start --json >/dev/null && "
+ "codex --remote 'unix://' -C "))
+ self.assertEqual(cmd.count("--remote 'unix://'"), 1)
+ self.assertIn(f"-C {agents.shlex.quote(cfg['agents']['cx']['dir'])}", cmd)
+ self.assertIn("--model 'gpt-test'", cmd)
+ self.assertIn(
+ "--dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust --no-alt-screen",
+ cmd)
+ self.assertIn("kickoff-aotest-ut-cx.txt", cmd)
+ self.assertTrue(any("1" in command for command in sent))
+ finally:
+ agents.session_alive, agents.new_session, agents.log = old_alive, old_new, old_log
+ agents.capture_pane, agents._run = old_capture, old_run
+ shutil.rmtree(tmp, ignore_errors=True)
+
# ── kickoff-template assembly ──────────────────────────────────────────────────────