Skip to content
Merged
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 @@ -319,8 +319,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>-main@<host>` | Claude Remote Control session name (null -> `<user>-<session>@<host>`, where `<host>` is `remoteControlHost`). 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>-main@<host>` | Claude Remote Control session name (null -> `<user>-<session>@<host>`, where `<host>` is `remoteControlHost`). 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 @@ -376,9 +376,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
17 changes: 10 additions & 7 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 <UserName>-main@<host> 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 @@ -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
Expand All @@ -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
Expand Down
88 changes: 80 additions & 8 deletions modules/agent-box.nix

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 80 additions & 8 deletions modules/agent-box.nix.in
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,43 @@ 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" ''
# 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
# 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 @@ -527,13 +564,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>-<session>@<host>", where <host> is
services.agent-box.remoteControlHost (fqdnOrHostName by default,
the public sslip.io host on the AWS image), else the public
Expand Down Expand Up @@ -629,16 +667,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 @@ -1009,7 +1056,32 @@ ${agentBinCases} *) return 1 ;;
[ -n "$prompt" ] && cmd="$cmd $(printf '%q' "$prompt")"
;;
codex)
if [ "$resuming" = true ]; then
if [ "$rc" = 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: 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
# 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).
Expand Down
Loading