diff --git a/README.md b/README.md index f8a7142..7d0fe62 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 @@ -320,8 +320,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` | `@` | Claude Remote Control session name (null -> `@` for `main`, `-@` for other sessions). 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` | `@` | Claude Remote Control session name (null -> `@` for `main`, `-@` for other sessions). 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. | @@ -379,9 +379,13 @@ 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 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/aws/template.yaml b/aws/template.yaml index 6111456..19d374c 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 @ session name applies to claude only (codex + runs the `codex remote-control` daemon, which names itself from the + hostname). UserName: Type: String @@ -774,8 +776,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 @@ -796,8 +798,9 @@ Outputs: Claude Remote Control session name (@). 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. + same Claude account). Only meaningful when Agent=claude; 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}@${AWS::StackName} PublicAddress: diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 52ada24..9529688 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -163,6 +163,37 @@ let exec "$@" ''; + # Codex Remote Control supervisor (issue 103). Unlike claude's + # `--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: 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" 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" 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 + 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. @@ -400,13 +431,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 "@" for the session named "main" and "-@" otherwise, where is networking.fqdnOrHostName, or the live kernel hostname when that @@ -481,16 +513,25 @@ 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: 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 { @@ -715,13 +756,37 @@ ${agentBinCases} *) return 1 ;; # (extraArgs, remoteControlName, cwd) can't inject into the tmux # command line — the runtime equivalent of lib.escapeShellArg. cmd="$(printf '%q' "$bin")" - if [ "$skip" = true ]; then + # 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, handled in + # its own branch below; every other case keeps the TUI + autonomy flag. + codex_remote=false + if [ "$agent" = codex ] && [ "$rc" = true ]; then codex_remote=true; fi + if [ "$skip" = true ] && [ "$codex_remote" = false ]; then case "$agent" in claude) cmd="$cmd --dangerously-skip-permissions" ;; codex) cmd="$cmd --dangerously-bypass-approvals-and-sandbox" ;; esac fi - if [ "$agent" = claude ] && [ "$rc" = true ]; then + if [ "$codex_remote" = true ]; then + # Run the codex app-server daemon under the foreground supervisor + # wrapper (the daemon detaches — see codexRemoteControl). The wrapper + # 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 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. + cmd="$(printf '%q' ${codexRemoteControl}) $cmd" + if [ "$skip" = true ]; then + cmd="$cmd -c approval_policy=never -c sandbox_mode=danger-full-access" + fi + elif [ "$agent" = claude ] && [ "$rc" = true ]; then if [ -z "$rcname" ]; then # Host suffix for the derived "[-]@" name. # ${fqdn} is config.networking.fqdnOrHostName, fixed at build diff --git a/tests/sessions.nix b/tests/sessions.nix index bdb9d13..52d7706 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -187,6 +187,28 @@ "/home/agent/.config/agent-box/sessions.json" ) + # Codex honours remoteControl (issue 103): with the default + # 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). + 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(