Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -320,8 +320,8 @@ All under `services.agent-box`:
| `users.<name>.sessions.<s>.*` | `{}` | 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.<name>.agent` | `null` | Agent for the default `main` session; null uses `services.agent-box.agent`. |
| `users.<name>.skipPermissions` | `true` | Pass the selected agent's autonomy flag. |
| `users.<name>.remoteControl` | `true` | Pass Claude's `--remote-control` when `agent = "claude"`; ignored for Codex. |
| `users.<name>.remoteControlName` | `<name>@<host>` | Claude Remote Control session name (null -> `<user>@<fqdnOrHostName>` for `main`, `<user>-<session>@<fqdnOrHostName>` for other sessions). Ignored for Codex. |
| `users.<name>.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.<name>.remoteControlName` | `<name>@<host>` | Claude Remote Control session name (null -> `<user>@<fqdnOrHostName>` for `main`, `<user>-<session>@<fqdnOrHostName>` for other sessions). Ignored for Codex (its daemon names itself from the hostname). |
| `users.<name>.workingDirectory` | `/home/<name>` | Agent startup directory. |
| `users.<name>.extraGroups` | `[]` | Extra groups for the user. |
| `users.<name>.extraArgs` | `[]` | Extra args appended to the selected agent CLI. |
Expand Down Expand Up @@ -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:**

Expand Down
19 changes: 11 additions & 8 deletions aws/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 <user>@<stack> session name applies to claude only (codex
runs the `codex remote-control` daemon, which names itself from the
hostname).

UserName:
Type: String
Expand Down Expand Up @@ -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
Expand All @@ -796,8 +798,9 @@ Outputs:
Claude Remote Control session name (<UserName>@<stack 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:
Expand Down
83 changes: 74 additions & 9 deletions modules/agent-box.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -400,13 +431,14 @@ let
remoteControl = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Pass --remote-control (claude sessions only; see users.<name>.remoteControl).";
description = "Make the session drivable from the agent's apps (see users.<name>.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
"<user>@<host>" for the session named "main" and
"<user>-<session>@<host>" otherwise, where <host> is
networking.fqdnOrHostName, or the live kernel hostname when that
Expand Down Expand Up @@ -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 <remoteControlName>` 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 {
Expand Down Expand Up @@ -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 <name>`
# 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 "<user>[-<session>]@<host>" name.
# ${fqdn} is config.networking.fqdnOrHostName, fixed at build
Expand Down
22 changes: 22 additions & 0 deletions tests/sessions.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading