From 7dfc0537794455af2b5aaaa3822f68819e39d9c0 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 14:37:38 +0000 Subject: [PATCH 1/3] feat(sessions): drive Codex sessions via Remote Control (issue #103) Wire codex into the remoteControl option instead of ignoring it. Unlike claude's `--remote-control` TUI flag, codex ships a dedicated app-server daemon (`codex remote-control {start,stop,pair}`) that the Codex apps drive. `start` detaches, so it can't be a session's foreground command directly; add a supervisor wrapper that (re)starts the daemon, blocks on its control socket, and tears it down on session kill/restart so no detached daemon is leaked. skipPermissions rides in as `-c approval_policy=never -c sandbox_mode=danger-full-access` since the subcommand rejects the TUI's --dangerously-bypass flag. remoteControlName stays claude-only (the codex daemon names itself from the hostname). Update README, AWS template docs, and add a NixOS test asserting the wrapper runs and the daemon answers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EdKG7KmEiwGB4AtLfddYLT --- README.md | 15 +++++--- aws/template.yaml | 17 +++++---- modules/agent-box.nix | 81 ++++++++++++++++++++++++++++++++++++---- modules/agent-box.nix.in | 81 ++++++++++++++++++++++++++++++++++++---- tests/sessions.nix | 21 +++++++++++ 5 files changed, 186 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index da55d6c..0312213 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Supported agents: | Agent | Package | Autonomy flag used by `skipPermissions = true` | Notes | | --- | --- | --- | --- | | Claude Code | `pkgs.claude-code` | `--dangerously-skip-permissions` | Supports Claude Remote Control. | -| Codex | `pkgs.codex` | `--dangerously-bypass-approvals-and-sandbox` | Browser terminal access; Codex app-server/remote wiring is future work. | +| Codex | `pkgs.codex` | `--dangerously-bypass-approvals-and-sandbox` | Browser terminal, plus Remote Control via the `codex remote-control` daemon (`remoteControl = true`). | ## 1-click AWS launch @@ -319,8 +319,8 @@ All under `services.agent-box`: | `users..sessions..*` | `{}` | Seed sessions (first boot only): per session `agent`, `skipPermissions`, `remoteControl`, `remoteControlName`, `workingDirectory`, `extraArgs`. Empty = the legacy per-user options below seed a session named `main`. | | `users..agent` | `null` | Agent for the default `main` session; null uses `services.agent-box.agent`. | | `users..skipPermissions` | `true` | Pass the selected agent's autonomy flag. | -| `users..remoteControl` | `true` | Pass Claude's `--remote-control` when `agent = "claude"`; ignored for Codex. | -| `users..remoteControlName` | `-main@` | Claude Remote Control session name (null -> `-@`, where `` is `remoteControlHost`). Ignored for Codex. | +| `users..remoteControl` | `true` | Make the session drivable from the agent's apps: claude gets `--remote-control`; codex runs the `codex remote-control` daemon instead of its TUI. | +| `users..remoteControlName` | `-main@` | Claude Remote Control session name (null -> `-@`, where `` is `remoteControlHost`). Ignored for Codex (its daemon names itself from the hostname). | | `users..workingDirectory` | `/home/` | Agent startup directory. | | `users..extraGroups` | `[]` | Extra groups for the user. | | `users..extraArgs` | `[]` | Extra args appended to the selected agent CLI. | @@ -376,9 +376,12 @@ arbitrary command execution as the agent user. - `skipPermissions = true` - a headless agent runner with per-tool approval prompts and no human to answer them is useless. Flip to `false` per-user if you actually have a human at the terminal. -- `remoteControl = true` - for Claude Code, this is the "drive it from your - phone" feature. Flip to `false` per-user if you don't want the session - reachable from the Claude apps. Codex ignores this option. +- `remoteControl = true` - the "drive it from your phone" feature. For Claude + Code it adds `--remote-control`; for Codex it runs the + `codex remote-control start` app-server daemon instead of the interactive + TUI (pair a box with `codex remote-control pair`). Flip to `false` per-user + if you don't want the session reachable from the agent's apps — then Codex + runs its normal TUI, reachable via the browser terminal. **Tradeoffs the module can't fully paper over:** diff --git a/aws/template.yaml b/aws/template.yaml index 1519673..62f4d0c 100644 --- a/aws/template.yaml +++ b/aws/template.yaml @@ -60,8 +60,8 @@ Metadata: Description: > agent-box - 1-click NixOS host running a coding agent inside a persistent tmux session. Choose Claude Code or Codex at deployment time. Reach it through - a browser terminal (Caddy + ttyd); Claude Code sessions can also be driven - from the Claude desktop and mobile apps via Remote Control. + a browser terminal (Caddy + ttyd); sessions can also be driven + from the agent's desktop and mobile apps via Remote Control. Source: https://github.com/defangdevs/agent-box Parameters: @@ -73,8 +73,10 @@ Parameters: - codex Description: >- Agent CLI to run in the persistent tmux session. Supported values: - claude (Claude Code) and codex (OpenAI Codex CLI). Claude-specific - Remote Control settings are ignored when codex is selected. + claude (Claude Code) and codex (OpenAI Codex CLI). Both support Remote + Control; the -main@ session name applies to claude only + (codex runs the `codex remote-control` daemon, which names itself from + the hostname). UserName: Type: String @@ -783,8 +785,8 @@ Resources: Outputs: WebURL: Description: >- - Browser terminal for the selected agent. Claude Code deployments are also - drivable from the Claude apps via Remote Control after the first login. + Browser terminal for the selected agent. Deployments are also + drivable from the agent's apps via Remote Control after the first login. First load takes ~2-3 minutes while the instance provisions and Caddy issues an ACME cert. Sign in with the UserName (default "agent") and the WebPassword chosen at deployment time. (The URL deliberately carries no @@ -806,7 +808,8 @@ Outputs: box's public address). After finishing `claude login` once in the browser terminal, the Claude desktop / mobile apps can drive this session directly (sign in with the same Claude account). Only meaningful when Agent=claude; - Codex has no Remote Control channel. + Codex is remote-controlled too, but its `codex remote-control` daemon + names itself from the hostname (pair it with `codex remote-control pair`). Value: !Sub - "${UserName}-main@${Host}.sslip.io" - Host: !If diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 3516477..7b2528a 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -192,6 +192,36 @@ let exec "$@" ''; + # Codex Remote Control supervisor (issue 103). Unlike claude's + # `--remote-control` TUI flag, codex ships a dedicated app-server daemon + # driven by `codex remote-control {start,stop,pair}`. `start` forks a + # DETACHED daemon (it reparents to init) and returns immediately, so it + # can't be a session's foreground command directly — the tmux session would + # exit at once and the reconcile loop would respawn it every ~2s while the + # real daemon ran unsupervised. This wrapper is that foreground command + # instead: it (re)starts the daemon, then blocks for as long as the daemon's + # control socket answers, so the session's life tracks the daemon's — a + # daemon crash exits the wrapper and the supervisor respawns a fresh one. It + # also tears the daemon down when the session is killed or restarted (tmux + # sends SIGHUP; a whole-service stop already cgroup-kills, but a single + # session bounce does not), so no detached daemon is ever leaked. $1 is the + # codex binary; the rest is forwarded to `remote-control start` (the -c + # autonomy overrides seeded below, plus any extraArgs). + codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' + codex=$1; shift + stop() { "$codex" remote-control stop >/dev/null 2>&1 || true; } + # A daemon left over from an earlier start would make ours a no-op and + # leave us supervising nothing, so clear it first, then own a fresh one. + stop + trap 'stop; exit 0' HUP INT TERM + "$codex" remote-control start "$@" || { stop; exit 1; } + # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at + # once and the trap fires without waiting the interval out. + while "$codex" app-server daemon version >/dev/null 2>&1; do + sleep 5 & wait $! + done + ''; + # Reload command is granted when web is enabled so the agent can add a # virtual host and reload without root — pooled with the user-supplied # sudoAllowlist so NoNewPrivileges + sudo rules see the same list. @@ -531,13 +561,14 @@ let remoteControl = lib.mkOption { type = lib.types.bool; default = true; - description = "Pass --remote-control (claude sessions only; see users..remoteControl)."; + description = "Make the session drivable from the agent's apps (see users..remoteControl)."; }; remoteControlName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' - Remote Control session name. When null, defaults to + Remote Control session name (claude sessions only — codex derives + its own name from the hostname). When null, defaults to "-@", where is services.agent-box.remoteControlHost (fqdnOrHostName by default, the public sslip.io host on the AWS image), else the public @@ -633,16 +664,24 @@ let type = lib.types.bool; default = true; description = '' - Pass --remote-control so the session is drivable from the Claude - desktop and mobile apps. Default `true` because "drive it from - your phone" is one of the module's headline features. + Make the session drivable from the agent's desktop and mobile apps. + Default `true` because "drive it from your phone" is one of the + module's headline features. Set false to disable remote-app control for this user — then the agent is only reachable through the local tmux session (or the ttyd browser terminal in the AWS variant). - Applies only to the claude agent. Codex remote/app-server wiring is - separate future work; use extraArgs for explicit Codex flags. + How it is wired depends on the agent: + - claude: adds `--remote-control ` to the normal + TUI, so the browser terminal still shows the interactive session. + - codex: runs `codex remote-control start` — the app-server daemon + the Codex apps drive (issue 103) — INSTEAD of the interactive TUI. + Pair a running box with `codex remote-control pair`. With false, + codex runs its normal TUI, reachable only via the browser terminal. + The codex daemon is a per-user singleton (one control socket per + user), so enable it on at most ONE codex session per user; + two would fight over the same daemon. ''; }; remoteControlName = lib.mkOption { @@ -1013,7 +1052,33 @@ ${agentBinCases} *) return 1 ;; [ -n "$prompt" ] && cmd="$cmd $(printf '%q' "$prompt")" ;; codex) - if [ "$resuming" = true ]; then + if [ "$rc" = true ]; then + # Codex remote control is a dedicated daemon subcommand, not a + # TUI flag (issue 103): `codex remote-control start` runs the + # app-server daemon the Codex desktop/mobile apps drive, whereas + # claude takes a `--remote-control ` flag on its normal + # TUI. So a remote-controlled codex session runs a DIFFERENT + # program: the foreground supervisor wrapper (the daemon itself + # detaches — see codexRemoteControl), which takes the codex + # binary as its first arg and forwards the rest to + # `remote-control start`. That subcommand rejects + # --dangerously-bypass-approvals-and-sandbox, so honour + # skipPermissions via the two -c overrides that flag sets + # (codex's documented config-override path). A bare value that + # isn't valid TOML is taken as a string literal, so no quoting is + # needed. remoteControlName is claude-only — the codex daemon + # derives its own machine name from the hostname. Pairing the + # Codex apps to a running daemon uses `codex remote-control + # pair`; the standalone-path shim seeded above is what lets the + # Nix codex serve as the app-server. The daemon takes no + # positional prompt and has no TUI transcript to resume, so the + # kickoff/resume wiring below does not apply to it. + cmd="$(printf '%q' ${codexRemoteControl}) $cmd" + if [ "$skip" = true ]; then + cmd="$cmd -c approval_policy=never -c sandbox_mode=danger-full-access" + fi + append_extra + elif [ "$resuming" = true ]; then # Find THIS session's transcript by our injected marker. A concrete # match → resume it; no match → start fresh (never `resume --last`, # which could grab a sibling session's transcript in a shared cwd). diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index c3e9a56..e3dcdfe 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -188,6 +188,36 @@ let exec "$@" ''; + # Codex Remote Control supervisor (issue 103). Unlike claude's + # `--remote-control` TUI flag, codex ships a dedicated app-server daemon + # driven by `codex remote-control {start,stop,pair}`. `start` forks a + # DETACHED daemon (it reparents to init) and returns immediately, so it + # can't be a session's foreground command directly — the tmux session would + # exit at once and the reconcile loop would respawn it every ~2s while the + # real daemon ran unsupervised. This wrapper is that foreground command + # instead: it (re)starts the daemon, then blocks for as long as the daemon's + # control socket answers, so the session's life tracks the daemon's — a + # daemon crash exits the wrapper and the supervisor respawns a fresh one. It + # also tears the daemon down when the session is killed or restarted (tmux + # sends SIGHUP; a whole-service stop already cgroup-kills, but a single + # session bounce does not), so no detached daemon is ever leaked. $1 is the + # codex binary; the rest is forwarded to `remote-control start` (the -c + # autonomy overrides seeded below, plus any extraArgs). + codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' + codex=$1; shift + stop() { "$codex" remote-control stop >/dev/null 2>&1 || true; } + # A daemon left over from an earlier start would make ours a no-op and + # leave us supervising nothing, so clear it first, then own a fresh one. + stop + trap 'stop; exit 0' HUP INT TERM + "$codex" remote-control start "$@" || { stop; exit 1; } + # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at + # once and the trap fires without waiting the interval out. + while "$codex" app-server daemon version >/dev/null 2>&1; do + sleep 5 & wait $! + done + ''; + # Reload command is granted when web is enabled so the agent can add a # virtual host and reload without root — pooled with the user-supplied # sudoAllowlist so NoNewPrivileges + sudo rules see the same list. @@ -527,13 +557,14 @@ let remoteControl = lib.mkOption { type = lib.types.bool; default = true; - description = "Pass --remote-control (claude sessions only; see users..remoteControl)."; + description = "Make the session drivable from the agent's apps (see users..remoteControl)."; }; remoteControlName = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' - Remote Control session name. When null, defaults to + Remote Control session name (claude sessions only — codex derives + its own name from the hostname). When null, defaults to "-@", where is services.agent-box.remoteControlHost (fqdnOrHostName by default, the public sslip.io host on the AWS image), else the public @@ -629,16 +660,24 @@ let type = lib.types.bool; default = true; description = '' - Pass --remote-control so the session is drivable from the Claude - desktop and mobile apps. Default `true` because "drive it from - your phone" is one of the module's headline features. + Make the session drivable from the agent's desktop and mobile apps. + Default `true` because "drive it from your phone" is one of the + module's headline features. Set false to disable remote-app control for this user — then the agent is only reachable through the local tmux session (or the ttyd browser terminal in the AWS variant). - Applies only to the claude agent. Codex remote/app-server wiring is - separate future work; use extraArgs for explicit Codex flags. + How it is wired depends on the agent: + - claude: adds `--remote-control ` to the normal + TUI, so the browser terminal still shows the interactive session. + - codex: runs `codex remote-control start` — the app-server daemon + the Codex apps drive (issue 103) — INSTEAD of the interactive TUI. + Pair a running box with `codex remote-control pair`. With false, + codex runs its normal TUI, reachable only via the browser terminal. + The codex daemon is a per-user singleton (one control socket per + user), so enable it on at most ONE codex session per user; + two would fight over the same daemon. ''; }; remoteControlName = lib.mkOption { @@ -1009,7 +1048,33 @@ ${agentBinCases} *) return 1 ;; [ -n "$prompt" ] && cmd="$cmd $(printf '%q' "$prompt")" ;; codex) - if [ "$resuming" = true ]; then + if [ "$rc" = true ]; then + # Codex remote control is a dedicated daemon subcommand, not a + # TUI flag (issue 103): `codex remote-control start` runs the + # app-server daemon the Codex desktop/mobile apps drive, whereas + # claude takes a `--remote-control ` flag on its normal + # TUI. So a remote-controlled codex session runs a DIFFERENT + # program: the foreground supervisor wrapper (the daemon itself + # detaches — see codexRemoteControl), which takes the codex + # binary as its first arg and forwards the rest to + # `remote-control start`. That subcommand rejects + # --dangerously-bypass-approvals-and-sandbox, so honour + # skipPermissions via the two -c overrides that flag sets + # (codex's documented config-override path). A bare value that + # isn't valid TOML is taken as a string literal, so no quoting is + # needed. remoteControlName is claude-only — the codex daemon + # derives its own machine name from the hostname. Pairing the + # Codex apps to a running daemon uses `codex remote-control + # pair`; the standalone-path shim seeded above is what lets the + # Nix codex serve as the app-server. The daemon takes no + # positional prompt and has no TUI transcript to resume, so the + # kickoff/resume wiring below does not apply to it. + cmd="$(printf '%q' ${codexRemoteControl}) $cmd" + if [ "$skip" = true ]; then + cmd="$cmd -c approval_policy=never -c sandbox_mode=danger-full-access" + fi + append_extra + elif [ "$resuming" = true ]; then # Find THIS session's transcript by our injected marker. A concrete # match → resume it; no match → start fresh (never `resume --last`, # which could grab a sibling session's transcript in a shared cwd). diff --git a/tests/sessions.nix b/tests/sessions.nix index f61afab..b179bbb 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -208,6 +208,27 @@ "/home/agent/.config/agent-box/sessions.json" ) + # Codex honours remoteControl (issue 103): with the default + # remoteControl=true, a codex session drives the `codex remote-control` + # app-server daemon (the mode the Codex apps pair with), NOT the + # interactive TUI. The daemon detaches, so the session's foreground + # command is the agent-box supervisor wrapper that owns its lifecycle; + # assert the wrapper runs and passes the autonomy -c overrides (the + # subcommand rejects the TUI's --dangerously-bypass flag, so skipPermissions + # rides in as -c approval_policy / sandbox_mode instead). + helper_cmdline = machine.wait_until_succeeds( + as_agent("pgrep -u agent -af agent-box-codex-remote-control"), timeout=60 + ) + assert "-c approval_policy=never" in helper_cmdline, helper_cmdline + assert "-c sandbox_mode=danger-full-access" in helper_cmdline, helper_cmdline + assert "--dangerously-bypass-approvals-and-sandbox" not in helper_cmdline, helper_cmdline + # The wrapper actually brings the daemon up: its control socket answers + # (`app-server daemon version` exits 0 only against a live daemon). This + # needs no codex login — starting the daemon is separate from pairing. + machine.wait_until_succeeds( + as_agent("codex app-server daemon version"), timeout=60 + ) + # Re-adding an existing name errors out and must not clobber the stored # config (issue 100): helper keeps its codex agent. machine.fail( From fdaf1f56b86a07100eae470ed937a7caf65aba37 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 09:30:17 +0000 Subject: [PATCH 2/3] fix(sessions): start Codex remote control offline --- README.md | 11 ++++---- modules/agent-box.nix | 61 ++++++++++++++++++++-------------------- modules/agent-box.nix.in | 61 ++++++++++++++++++++-------------------- tests/sessions.nix | 9 +++--- 4 files changed, 73 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 0312213..7bb3b5b 100644 --- a/README.md +++ b/README.md @@ -377,11 +377,12 @@ arbitrary command execution as the agent user. approval prompts and no human to answer them is useless. Flip to `false` per-user if you actually have a human at the terminal. - `remoteControl = true` - the "drive it from your phone" feature. For Claude - Code it adds `--remote-control`; for Codex it runs the - `codex remote-control start` app-server daemon instead of the interactive - TUI (pair a box with `codex remote-control pair`). Flip to `false` per-user - if you don't want the session reachable from the agent's apps — then Codex - runs its normal TUI, reachable via the browser terminal. + Code it adds `--remote-control`; for Codex it starts the local app-server + daemon, enables Remote Control on it, and lets the relay connect once login + and network are available instead of requiring either at daemon startup + (pair a box with `codex remote-control pair`). Flip to `false` per-user if + you don't want the session reachable from the agent's apps — then Codex runs + its normal TUI, reachable via the browser terminal. **Tradeoffs the module can't fully paper over:** diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 7b2528a..ffc6a01 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -193,28 +193,29 @@ let ''; # Codex Remote Control supervisor (issue 103). Unlike claude's - # `--remote-control` TUI flag, codex ships a dedicated app-server daemon - # driven by `codex remote-control {start,stop,pair}`. `start` forks a - # DETACHED daemon (it reparents to init) and returns immediately, so it + # `--remote-control` TUI flag, codex uses a dedicated app-server daemon. + # `remote-control start` eagerly connects to the backend and fails before + # creating the local control socket when the user is logged out or the + # network is unavailable. Start the offline-safe local daemon first, then + # enable Remote Control independently so the relay can attach once login and + # network are available. The local daemon detaches (reparents to init), so it # can't be a session's foreground command directly — the tmux session would # exit at once and the reconcile loop would respawn it every ~2s while the - # real daemon ran unsupervised. This wrapper is that foreground command - # instead: it (re)starts the daemon, then blocks for as long as the daemon's - # control socket answers, so the session's life tracks the daemon's — a - # daemon crash exits the wrapper and the supervisor respawns a fresh one. It - # also tears the daemon down when the session is killed or restarted (tmux - # sends SIGHUP; a whole-service stop already cgroup-kills, but a single - # session bounce does not), so no detached daemon is ever leaked. $1 is the - # codex binary; the rest is forwarded to `remote-control start` (the -c - # autonomy overrides seeded below, plus any extraArgs). + # real daemon ran unsupervised. This wrapper is that foreground command: it + # (re)starts the daemon, then blocks for as long as its control socket + # answers. It also tears the daemon down when the session is killed or + # restarted, so no detached daemon is leaked. $1 is the codex binary; the + # rest is forwarded to `app-server daemon start` (the -c autonomy overrides + # seeded below, plus any extraArgs). codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' codex=$1; shift - stop() { "$codex" remote-control stop >/dev/null 2>&1 || true; } + stop() { "$codex" app-server daemon stop >/dev/null 2>&1 || true; } # A daemon left over from an earlier start would make ours a no-op and # leave us supervising nothing, so clear it first, then own a fresh one. stop trap 'stop; exit 0' HUP INT TERM - "$codex" remote-control start "$@" || { stop; exit 1; } + "$codex" app-server daemon start "$@" || { stop; exit 1; } + "$codex" app-server daemon enable-remote-control >/dev/null || { stop; exit 1; } # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at # once and the trap fires without waiting the interval out. while "$codex" app-server daemon version >/dev/null 2>&1; do @@ -675,13 +676,14 @@ let How it is wired depends on the agent: - claude: adds `--remote-control ` to the normal TUI, so the browser terminal still shows the interactive session. - - codex: runs `codex remote-control start` — the app-server daemon - the Codex apps drive (issue 103) — INSTEAD of the interactive TUI. - Pair a running box with `codex remote-control pair`. With false, - codex runs its normal TUI, reachable only via the browser terminal. - The codex daemon is a per-user singleton (one control socket per - user), so enable it on at most ONE codex session per user; - two would fight over the same daemon. + - codex: starts the local app-server daemon and enables Remote Control + on it (issue 103) INSTEAD of the interactive TUI. The daemon starts + even while logged out or offline; its relay connects once login and + network are available. Pair a running box with `codex remote-control + pair`. With false, codex runs its normal TUI, reachable only via the + browser terminal. The codex daemon is a per-user singleton (one + control socket per user), so enable it on at most ONE codex session + per user; two would fight over the same daemon. ''; }; remoteControlName = lib.mkOption { @@ -1053,15 +1055,14 @@ ${agentBinCases} *) return 1 ;; ;; codex) if [ "$rc" = true ]; then - # Codex remote control is a dedicated daemon subcommand, not a - # TUI flag (issue 103): `codex remote-control start` runs the - # app-server daemon the Codex desktop/mobile apps drive, whereas - # claude takes a `--remote-control ` flag on its normal - # TUI. So a remote-controlled codex session runs a DIFFERENT - # program: the foreground supervisor wrapper (the daemon itself - # detaches — see codexRemoteControl), which takes the codex - # binary as its first arg and forwards the rest to - # `remote-control start`. That subcommand rejects + # Codex remote control uses a dedicated app-server daemon, not a + # TUI flag (issue 103), whereas claude takes a + # `--remote-control ` flag on its normal TUI. So a + # remote-controlled codex session runs a DIFFERENT program: the + # foreground supervisor wrapper (the daemon itself detaches — + # see codexRemoteControl), which takes the codex binary as its + # first arg and forwards the rest to + # `app-server daemon start`. That subcommand rejects # --dangerously-bypass-approvals-and-sandbox, so honour # skipPermissions via the two -c overrides that flag sets # (codex's documented config-override path). A bare value that diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index e3dcdfe..64b788e 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -189,28 +189,29 @@ let ''; # Codex Remote Control supervisor (issue 103). Unlike claude's - # `--remote-control` TUI flag, codex ships a dedicated app-server daemon - # driven by `codex remote-control {start,stop,pair}`. `start` forks a - # DETACHED daemon (it reparents to init) and returns immediately, so it + # `--remote-control` TUI flag, codex uses a dedicated app-server daemon. + # `remote-control start` eagerly connects to the backend and fails before + # creating the local control socket when the user is logged out or the + # network is unavailable. Start the offline-safe local daemon first, then + # enable Remote Control independently so the relay can attach once login and + # network are available. The local daemon detaches (reparents to init), so it # can't be a session's foreground command directly — the tmux session would # exit at once and the reconcile loop would respawn it every ~2s while the - # real daemon ran unsupervised. This wrapper is that foreground command - # instead: it (re)starts the daemon, then blocks for as long as the daemon's - # control socket answers, so the session's life tracks the daemon's — a - # daemon crash exits the wrapper and the supervisor respawns a fresh one. It - # also tears the daemon down when the session is killed or restarted (tmux - # sends SIGHUP; a whole-service stop already cgroup-kills, but a single - # session bounce does not), so no detached daemon is ever leaked. $1 is the - # codex binary; the rest is forwarded to `remote-control start` (the -c - # autonomy overrides seeded below, plus any extraArgs). + # real daemon ran unsupervised. This wrapper is that foreground command: it + # (re)starts the daemon, then blocks for as long as its control socket + # answers. It also tears the daemon down when the session is killed or + # restarted, so no detached daemon is leaked. $1 is the codex binary; the + # rest is forwarded to `app-server daemon start` (the -c autonomy overrides + # seeded below, plus any extraArgs). codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' codex=$1; shift - stop() { "$codex" remote-control stop >/dev/null 2>&1 || true; } + stop() { "$codex" app-server daemon stop >/dev/null 2>&1 || true; } # A daemon left over from an earlier start would make ours a no-op and # leave us supervising nothing, so clear it first, then own a fresh one. stop trap 'stop; exit 0' HUP INT TERM - "$codex" remote-control start "$@" || { stop; exit 1; } + "$codex" app-server daemon start "$@" || { stop; exit 1; } + "$codex" app-server daemon enable-remote-control >/dev/null || { stop; exit 1; } # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at # once and the trap fires without waiting the interval out. while "$codex" app-server daemon version >/dev/null 2>&1; do @@ -671,13 +672,14 @@ let How it is wired depends on the agent: - claude: adds `--remote-control ` to the normal TUI, so the browser terminal still shows the interactive session. - - codex: runs `codex remote-control start` — the app-server daemon - the Codex apps drive (issue 103) — INSTEAD of the interactive TUI. - Pair a running box with `codex remote-control pair`. With false, - codex runs its normal TUI, reachable only via the browser terminal. - The codex daemon is a per-user singleton (one control socket per - user), so enable it on at most ONE codex session per user; - two would fight over the same daemon. + - codex: starts the local app-server daemon and enables Remote Control + on it (issue 103) INSTEAD of the interactive TUI. The daemon starts + even while logged out or offline; its relay connects once login and + network are available. Pair a running box with `codex remote-control + pair`. With false, codex runs its normal TUI, reachable only via the + browser terminal. The codex daemon is a per-user singleton (one + control socket per user), so enable it on at most ONE codex session + per user; two would fight over the same daemon. ''; }; remoteControlName = lib.mkOption { @@ -1049,15 +1051,14 @@ ${agentBinCases} *) return 1 ;; ;; codex) if [ "$rc" = true ]; then - # Codex remote control is a dedicated daemon subcommand, not a - # TUI flag (issue 103): `codex remote-control start` runs the - # app-server daemon the Codex desktop/mobile apps drive, whereas - # claude takes a `--remote-control ` flag on its normal - # TUI. So a remote-controlled codex session runs a DIFFERENT - # program: the foreground supervisor wrapper (the daemon itself - # detaches — see codexRemoteControl), which takes the codex - # binary as its first arg and forwards the rest to - # `remote-control start`. That subcommand rejects + # Codex remote control uses a dedicated app-server daemon, not a + # TUI flag (issue 103), whereas claude takes a + # `--remote-control ` flag on its normal TUI. So a + # remote-controlled codex session runs a DIFFERENT program: the + # foreground supervisor wrapper (the daemon itself detaches — + # see codexRemoteControl), which takes the codex binary as its + # first arg and forwards the rest to + # `app-server daemon start`. That subcommand rejects # --dangerously-bypass-approvals-and-sandbox, so honour # skipPermissions via the two -c overrides that flag sets # (codex's documented config-override path). A bare value that diff --git a/tests/sessions.nix b/tests/sessions.nix index b179bbb..b66dc9a 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -209,10 +209,11 @@ ) # Codex honours remoteControl (issue 103): with the default - # remoteControl=true, a codex session drives the `codex remote-control` - # app-server daemon (the mode the Codex apps pair with), NOT the - # interactive TUI. The daemon detaches, so the session's foreground - # command is the agent-box supervisor wrapper that owns its lifecycle; + # remoteControl=true, a codex session starts the local app-server daemon, + # enables Remote Control on it, and does NOT run the interactive TUI. The + # offline-safe local start matters here because the VM has no Codex login. + # The daemon detaches, so the session's foreground command is the agent-box + # supervisor wrapper that owns its lifecycle; # assert the wrapper runs and passes the autonomy -c overrides (the # subcommand rejects the TUI's --dangerously-bypass flag, so skipPermissions # rides in as -c approval_policy / sandbox_mode instead). From dc640e950a59d440d68d14c3125433e58b4ec9f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:59:25 +0000 Subject: [PATCH 3/3] fix(sessions): put procps on PATH for the Codex daemon supervisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessions VM test never saw the codex control socket appear: every `app-server daemon start` inside the agent unit died with Error: failed to record pid-managed app-server process startup Caused by: failed to invoke ps for pid-managed app server No such file or directory (os error 2) The pid-managed daemon backend records and probes its app-server child by shelling out to a bare `ps`, and the agent unit runs with a curated PATH that has no procps on this branch. The wrapper then hit its `|| { stop; exit 1; }` path, so the session fell through to the post-mortem shell with no daemon and no socket — while the test's own pgrep kept passing because, run via `su ... -c`, it matched its own invoking shell's command line rather than the wrapper. So append procps to the wrapper's own PATH (the feature must not depend on how the agent PATH is curated), and run the test's pgrep as root so `-u agent` filters the invoking shell out and the assertion means what it says. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0191AtHPdSKYEWzR85Q5m4bU --- modules/agent-box.nix | 6 ++++++ modules/agent-box.nix.in | 6 ++++++ tests/sessions.nix | 5 ++++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index ffc6a01..3dcffc2 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -208,6 +208,12 @@ let # rest is forwarded to `app-server daemon start` (the -c autonomy overrides # seeded below, plus any extraArgs). codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' + # The pid-managed daemon backend records and probes its app-server child by + # shelling out to a bare `ps`, so procps has to be reachable regardless of + # how the agent unit's PATH is curated. Without it every start dies with + # "failed to invoke ps for pid-managed app server" and no control socket is + # ever created — appended, so a session's own PATH still wins. + PATH=$PATH:${pkgs.procps}/bin codex=$1; shift stop() { "$codex" app-server daemon stop >/dev/null 2>&1 || true; } # A daemon left over from an earlier start would make ours a no-op and diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 64b788e..d4c120a 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -204,6 +204,12 @@ let # rest is forwarded to `app-server daemon start` (the -c autonomy overrides # seeded below, plus any extraArgs). codexRemoteControl = pkgs.writeShellScript "agent-box-codex-remote-control" '' + # The pid-managed daemon backend records and probes its app-server child by + # shelling out to a bare `ps`, so procps has to be reachable regardless of + # how the agent unit's PATH is curated. Without it every start dies with + # "failed to invoke ps for pid-managed app server" and no control socket is + # ever created — appended, so a session's own PATH still wins. + PATH=$PATH:${pkgs.procps}/bin codex=$1; shift stop() { "$codex" app-server daemon stop >/dev/null 2>&1 || true; } # A daemon left over from an earlier start would make ours a no-op and diff --git a/tests/sessions.nix b/tests/sessions.nix index b66dc9a..6f474da 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -217,8 +217,11 @@ # assert the wrapper runs and passes the autonomy -c overrides (the # subcommand rejects the TUI's --dangerously-bypass flag, so skipPermissions # rides in as -c approval_policy / sandbox_mode instead). + # pgrep runs as ROOT (not via as_agent): `-u agent` then filters out the + # invoking shell, whose own command line contains the pattern — as the + # agent it self-matched and "succeeded" with the wrapper long dead. helper_cmdline = machine.wait_until_succeeds( - as_agent("pgrep -u agent -af agent-box-codex-remote-control"), timeout=60 + "pgrep -u agent -af agent-box-codex-remote-control", timeout=60 ) assert "-c approval_policy=never" in helper_cmdline, helper_cmdline assert "-c sandbox_mode=danger-full-access" in helper_cmdline, helper_cmdline