From 7d998459350c975925ca0e49229a11a14665aa85 Mon Sep 17 00:00:00 2001 From: Lio Lunesu Date: Sat, 1 Aug 2026 09:27:41 +0000 Subject: [PATCH] fix(codex): guide the RC session and give it a usable machine name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A codex session with remoteControl = true showed one line of daemon-start JSON and then nothing, forever. It is not a TUI, so typing did nothing, and nothing said that Remote Control cannot enroll until someone signs in — while README told the quickstart user to complete sign-in in that very tab. Pairing then failed with "remote control pairing is unavailable until enrollment completes", which never mentions login, and the flow that actually works headless (`codex login --device-auth`) appeared nowhere in this repo. Replace the blank pane with a status screen: what the pane is, the machine name, and whichever of sign-in / pairing is outstanding, with the exact commands. Sign-in happens in another tab, so the health loop polls for the transition and confirms when it lands. Daemon-start JSON goes to the log instead of being the first impression, and both failure paths now say what failed and point at app-server.stderr.log rather than dumping into an unexplained post-mortem shell. Also fix the name the Codex apps show. It came from gethostname(2) — the internal cloud fqdn — and codex offers no override: HOSTNAME does not occur in the binary, every candidate -c key is rejected by --strict-config, and disassembly shows the gethostname crate result fed straight to the serverName notification. networking.hostName cannot hold a dotted public name either (NixOS types it strMatching without dots). So the wrapper re-execs in a private UTS namespace whose hostname is hostLabel — the same public address claude sessions already use. The user namespace is unprivileged (--keep-caps grants CAP_SYS_ADMIN inside the namespace only, uid unchanged), and unshare execs in place, so the wrapper keeps its pid and its trap-based daemon teardown. Verified on an aarch64 box: serverName goes from ip-10-42-1-247.eu-central-1.compute.internal to 3.79.168.78.sslip.io, read back via `codex app-server` on stdio with a throwaway CODEX_HOME. Refs #159. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PsqGhkpjsb4kKAdVz25xKN --- README.md | 26 +++++++++- modules/agent-box.nix | 103 ++++++++++++++++++++++++++++++++++++--- modules/agent-box.nix.in | 103 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 217 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d7f89c2..af8eebf 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, plus Remote Control via the `codex remote-control` daemon (`remoteControl = true`). | +| Codex | `pkgs.codex` | `--dangerously-bypass-approvals-and-sandbox` | Per session, *either* the TUI in the browser terminal *or* Remote Control via the `codex remote-control` daemon (`remoteControl = true`) - not both. | ## 1-click AWS launch @@ -217,6 +217,30 @@ The auth code input is hidden like a password, so pasting gives no visible feedback. Paste the code, press Enter, and Claude Code should print `Login successful.`. +**Codex first login and pairing the Codex apps:** a Codex session with +`remoteControl = true` runs the app-server daemon, *not* a TUI - so its tab is +a status view and there is nothing to type into. Sign in from any other shell +on the box (another terminal tab, or +`agent-box-session add sh --agent shell`): + +```bash +codex login --device-auth # prints a URL + one-time code; expires in 15 min +codex remote-control pair # prints the short-lived pairing code for the apps +``` + +Use `--device-auth`. Plain `codex login` starts a callback server on +`localhost:1455`, which the browser on your laptop cannot reach. Until sign-in +completes, `codex remote-control pair` fails with `remote control pairing is +unavailable until enrollment completes` - that error means "not signed in". +The session's status pane reports which of the two steps is outstanding and +confirms when sign-in lands. + +The Codex apps label the box with the name the daemon reports, which Codex +takes from `gethostname(2)` with no env var, config key or flag to override it. +agent-box therefore runs the daemon in a private UTS namespace whose hostname +is the box's public address, so it appears as e.g. `1-2-3-4.sslip.io` rather +than an internal cloud hostname. `remoteControlName` remains Claude-only. + ## Sessions (any user can run any agent — no rebuild) A linux user account and an agent CLI are decoupled: each user runs one or diff --git a/modules/agent-box.nix b/modules/agent-box.nix index 6d4abb1..69c1c14 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -303,17 +303,104 @@ let # "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 + # The name the Codex apps label this box with ("serverName") comes straight + # from gethostname(2): codex exposes no env var, config key or flag for it + # (HOSTNAME does not even appear in the binary, and every candidate -c key + # is rejected by --strict-config). On a cloud box the kernel name is the + # INTERNAL fqdn (ip-10-x-x-x..compute.internal), which is useless in + # the apps — so when a public host label is configured, re-exec inside a + # private UTS namespace whose hostname is that label. An UNPRIVILEGED user + # namespace suffices: --keep-caps grants CAP_SYS_ADMIN inside the new + # namespace ONLY (the uid is unchanged, and the caps confer nothing + # outside), and unshare execs in place, so this stays the session's + # foreground pid and the trap below still tears the daemon down. The daemon + # inherits the namespace and keeps it alive after it detaches. + # /proc/sys/kernel/hostname is read-only under ProtectKernelTunables, so + # setting it needs a real sethostname(2) caller rather than a shell + # redirect. $1 is that label (empty = keep the kernel name). + rcname=$1; shift + if [ -n "$rcname" ] && [ -z "''${AGENT_BOX_CODEX_UTS:-}" ]; then + export AGENT_BOX_CODEX_UTS=1 + exec ${pkgs.util-linux}/bin/unshare --user --map-current-user --keep-caps --uts \ + ${pkgs.runtimeShell} -c \ + '${pkgs.unixtools.hostname}/bin/hostname "$1" || exit 1; shift; exec "$@"' \ + -- "$rcname" "$0" "$rcname" "$@" + fi 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; } + # This wrapper is the session's foreground command, so its stdout IS what + # the browser terminal shows. Without the banners below the pane is a lone + # line of daemon-start JSON and then silence forever, with no hint that the + # session is healthy, that it is not a codex prompt, or that Remote Control + # cannot enroll until someone signs in (issue #159). + hr() { printf '%s\n' "────────────────────────────────────────────────────────────"; } + signed_in() { "$codex" login status >/dev/null 2>&1; } + banner_signin() { + cat <&2 </dev/null || { daemon_failed "start"; stop; exit 1; } + "$codex" app-server daemon enable-remote-control >/dev/null \ + || { daemon_failed "enable remote control"; stop; exit 1; } + hr + printf '%s\n' " agent-box: codex Remote Control daemon is running." + printf '%s\n' " This pane is a status view, not a codex prompt — typing does nothing." + printf '%s\n' " Machine name in the Codex apps: $(uname -n)" + hr + if signed_in; then banner_ready; else banner_signin; fi # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at # once and the trap fires without waiting the interval out. + was_signed_in=false + signed_in && was_signed_in=true while "$codex" app-server daemon version >/dev/null 2>&1; do + # Sign-in happens in ANOTHER tab, so poll for the transition and confirm + # it here — otherwise the pane still reads "not signed in" long after it + # stopped being true, and the user has no idea pairing is now unblocked. + if [ "$was_signed_in" = false ] && signed_in; then + was_signed_in=true + banner_ready + fi sleep 5 & wait $! done ''; @@ -1377,21 +1464,23 @@ ${agentBinCases} *) return 1 ;; # `--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 + # see codexRemoteControl), which takes the host label and then + # the codex binary as its first two args 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 + # needed. remoteControlName is claude-only: the codex daemon takes + # its machine name from gethostname(2) with no override, so the + # wrapper gives it ${hostLabel} through a private UTS namespace + # instead (see codexRemoteControl). 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" + cmd="$(printf '%q' ${codexRemoteControl}) ${lib.escapeShellArg hostLabel} $cmd" if [ "$skip" = true ]; then cmd="$cmd -c approval_policy=never -c sandbox_mode=danger-full-access" fi diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 9c25e7a..de218f0 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -299,17 +299,104 @@ let # "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 + # The name the Codex apps label this box with ("serverName") comes straight + # from gethostname(2): codex exposes no env var, config key or flag for it + # (HOSTNAME does not even appear in the binary, and every candidate -c key + # is rejected by --strict-config). On a cloud box the kernel name is the + # INTERNAL fqdn (ip-10-x-x-x..compute.internal), which is useless in + # the apps — so when a public host label is configured, re-exec inside a + # private UTS namespace whose hostname is that label. An UNPRIVILEGED user + # namespace suffices: --keep-caps grants CAP_SYS_ADMIN inside the new + # namespace ONLY (the uid is unchanged, and the caps confer nothing + # outside), and unshare execs in place, so this stays the session's + # foreground pid and the trap below still tears the daemon down. The daemon + # inherits the namespace and keeps it alive after it detaches. + # /proc/sys/kernel/hostname is read-only under ProtectKernelTunables, so + # setting it needs a real sethostname(2) caller rather than a shell + # redirect. $1 is that label (empty = keep the kernel name). + rcname=$1; shift + if [ -n "$rcname" ] && [ -z "''${AGENT_BOX_CODEX_UTS:-}" ]; then + export AGENT_BOX_CODEX_UTS=1 + exec ${pkgs.util-linux}/bin/unshare --user --map-current-user --keep-caps --uts \ + ${pkgs.runtimeShell} -c \ + '${pkgs.unixtools.hostname}/bin/hostname "$1" || exit 1; shift; exec "$@"' \ + -- "$rcname" "$0" "$rcname" "$@" + fi 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; } + # This wrapper is the session's foreground command, so its stdout IS what + # the browser terminal shows. Without the banners below the pane is a lone + # line of daemon-start JSON and then silence forever, with no hint that the + # session is healthy, that it is not a codex prompt, or that Remote Control + # cannot enroll until someone signs in (issue #159). + hr() { printf '%s\n' "────────────────────────────────────────────────────────────"; } + signed_in() { "$codex" login status >/dev/null 2>&1; } + banner_signin() { + cat <&2 </dev/null || { daemon_failed "start"; stop; exit 1; } + "$codex" app-server daemon enable-remote-control >/dev/null \ + || { daemon_failed "enable remote control"; stop; exit 1; } + hr + printf '%s\n' " agent-box: codex Remote Control daemon is running." + printf '%s\n' " This pane is a status view, not a codex prompt — typing does nothing." + printf '%s\n' " Machine name in the Codex apps: $(uname -n)" + hr + if signed_in; then banner_ready; else banner_signin; fi # `sleep & wait` (not a bare sleep) so a signal interrupts the wait at # once and the trap fires without waiting the interval out. + was_signed_in=false + signed_in && was_signed_in=true while "$codex" app-server daemon version >/dev/null 2>&1; do + # Sign-in happens in ANOTHER tab, so poll for the transition and confirm + # it here — otherwise the pane still reads "not signed in" long after it + # stopped being true, and the user has no idea pairing is now unblocked. + if [ "$was_signed_in" = false ] && signed_in; then + was_signed_in=true + banner_ready + fi sleep 5 & wait $! done ''; @@ -1373,21 +1460,23 @@ ${agentBinCases} *) return 1 ;; # `--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 + # see codexRemoteControl), which takes the host label and then + # the codex binary as its first two args 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 + # needed. remoteControlName is claude-only: the codex daemon takes + # its machine name from gethostname(2) with no override, so the + # wrapper gives it ${hostLabel} through a private UTS namespace + # instead (see codexRemoteControl). 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" + cmd="$(printf '%q' ${codexRemoteControl}) ${lib.escapeShellArg hostLabel} $cmd" if [ "$skip" = true ]; then cmd="$cmd -c approval_policy=never -c sandbox_mode=danger-full-access" fi