From 4bdb1a0cec95e6cbf0831c94ee4c4830a3ba2bb9 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 21 Jul 2026 07:40:56 +0000 Subject: [PATCH 1/4] fix(sessions): derive Remote Control names from a real, public host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-derived Remote Control names are "-@". Two things made unhelpful: - When remoteControlHost/fqdnOrHostName was empty the supervisor fell straight back to the live kernel hostname, which on a cloud box is the internal, non-routable fqdn (ip-10-x-x-x..compute.internal) — useless for identifying or reaching the box in the Claude apps. - The AWS image named the seeded "main" session after the CloudFormation stack, so it never reflected the box's public address either. Add services.agent-box.remoteControlHost and, when it is empty, fall back to the box's public web.domain (sslip.io on AWS, custom DNS on bare metal) before the kernel hostname. The AWS template now sets remoteControlHost to the public sslip.io host, so every session — the seeded "main" and any added at runtime — is named after the address the box is actually reached at. Also drop the "main" special case (review feedback): the derived name is now uniformly "-@", so "main" becomes "-main@" rather than a bare "@". Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01GfaHqHJWLGiS78pvGzoyjP --- README.md | 3 +- aws/README.md | 10 +++-- aws/template.yaml | 30 ++++++++++----- modules/agent-box.nix | 81 +++++++++++++++++++++++++++------------- modules/agent-box.nix.in | 81 +++++++++++++++++++++++++++------------- tests/sessions.nix | 17 +++++++++ 6 files changed, 156 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index f8a7142..b97293a 100644 --- a/README.md +++ b/README.md @@ -317,11 +317,12 @@ All under `services.agent-box`: | `agent` | `"claude"` | Default agent CLI: `"claude"` or `"codex"`. | | `package` | selected agent default | Override package to run for every agent user. | | `installAgents` | all supported | Agent CLIs installed on the box (independent of what sessions run). | +| `remoteControlHost` | `fqdnOrHostName` | Host label for the `@` suffix of auto-derived Remote Control names. Empty -> falls back to the public `web.domain`, then the live kernel hostname. The AWS image sets it to the box's public sslip.io host. | | `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..remoteControlName` | `-main@` | Claude Remote Control session name (null -> `-@`, where `` is `remoteControlHost`). Ignored for Codex. | | `users..workingDirectory` | `/home/` | Agent startup directory. | | `users..extraGroups` | `[]` | Extra groups for the user. | | `users..extraArgs` | `[]` | Extra args appended to the selected agent CLI. | diff --git a/aws/README.md b/aws/README.md index aaa9de8..af0f054 100644 --- a/aws/README.md +++ b/aws/README.md @@ -34,10 +34,12 @@ choose Claude Code or Codex. `user@` userinfo: Chrome answers the auth challenge with URL userinfo plus an empty password, and credentials typed into the prompt cannot override the URL-embedded identity (issue 56). -- `@` becomes the Claude Remote Control session name - (default user: `agent`). Rename the stack before launch if you want a - friendlier label in the Claude apps; post-deploy, this can still be changed - in the NixOS config. +- `-main@.sslip.io` becomes the Claude Remote Control session + name (default user: `agent`), where `.sslip.io` is the box's public + address — so the box is identifiable and reachable in the Claude apps. + Sessions added at runtime derive the same way (`-@...`). + Override per user via `remoteControlName`, or box-wide via + `services.agent-box.remoteControlHost`, in the NixOS config post-deploy. - The hostname `.sslip.io` is derived at CFN time via `Fn::Split ':' + Fn::Join '-'` on the NetworkInterface's PrimaryIpv6Address (IPv6 mode) or the EIP address (IPv4 mode). Consecutive `::` becomes an empty split diff --git a/aws/template.yaml b/aws/template.yaml index 8a755c0..1519673 100644 --- a/aws/template.yaml +++ b/aws/template.yaml @@ -84,7 +84,7 @@ Parameters: Linux user the agent CLI runs as. Doubles as the browser-terminal sign-in username (the basic-auth username picks the terminal), the // path in the WebURL, the home directory (/home/), and - the @ Remote Control session name. Lowercase letters, + the -main@ Remote Control session name. Lowercase letters, digits, "_" and "-", starting with a letter or "_", max 31 chars. # The picker annotates each value with vCPU/RAM because the CloudFormation @@ -625,9 +625,15 @@ Resources: services.agent-box = { enable = true; agent = "${Agent}"; + # Host suffix for auto-derived Remote Control names. Use the + # box's PUBLIC address (the sslip.io hostname the box is + # actually reached at) rather than networking.hostName (unset + # on the AWS image) or the internal EC2 fqdn. Every session — + # the seeded "main" and any added at runtime — is then + # identifiable in the Claude apps as "-@". + remoteControlHost = hostname; users."${UserName}" = { environment = { TERM = "xterm-256color"; }; - remoteControlName = "${UserName}@${StackName}"; web.passwordHashFile = "/var/lib/agent-box-web/password-hash"; # The AGENTS.md ADDENDUM from the CFN AgentsMd parameter, # spliced as-is into a Nix indented string; the module @@ -731,7 +737,6 @@ Resources: - !Split [':', !GetAtt NetworkInterface.PrimaryIpv6Address] WebPasswordBase64: Fn::Base64: !Ref WebPassword - StackName: !Ref AWS::StackName # First-boot health signal (issue 106): tell CloudFormation the # user-data config actually APPLIED. This unit only exists once # the rebuild switched into this config, which is exactly the @@ -797,12 +802,19 @@ Outputs: RemoteControlSession: Description: >- - 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. - Value: !Sub ${UserName}@${AWS::StackName} + Claude Remote Control session name (-main@.sslip.io, the + 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. + Value: !Sub + - "${UserName}-main@${Host}.sslip.io" + - Host: !If + - UseIpv4 + - !Ref EIP + - !Join + - '-' + - !Split [':', !GetAtt NetworkInterface.PrimaryIpv6Address] PublicAddress: Description: Elastic IP (IPv4 mode) or primary IPv6 (IPv6 mode). diff --git a/modules/agent-box.nix b/modules/agent-box.nix index b7fba72..b7db061 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -238,8 +238,8 @@ let sessions = lib.mapAttrs (sname: s: { agent = if s.agent != null then s.agent else cfg.agent; inherit (s) skipPermissions remoteControl extraArgs; - # null → the supervisor derives "@" (main) or - # "-@" at start time. + # null → the supervisor derives "-@" at + # start time (see remoteControlHost). remoteControlName = s.remoteControlName; workingDirectory = if s.workingDirectory != null then s.workingDirectory @@ -414,11 +414,12 @@ let default = null; description = '' Remote Control session name. When null, defaults to - "@" for the session named "main" and - "-@" otherwise, where is - networking.fqdnOrHostName, or the live kernel hostname when that - is empty at build time. When no hostname is resolvable the "@" - suffix is omitted (just "" / "-"). + "-@", where is + services.agent-box.remoteControlHost (fqdnOrHostName by default, + the public sslip.io host on the AWS image), else the public + services.agent-box.web.domain, else the live kernel hostname when + both are empty at build time. When no host is resolvable the + "@" suffix is omitted (just "-"). ''; }; workingDirectory = lib.mkOption { @@ -505,10 +506,12 @@ let default = null; description = '' Remote Control session name, used to correlate the session to this box - from the Claude apps. Keep it shell-safe (no spaces/quotes). When null, - defaults to "@", where is networking.fqdnOrHostName, - or the live kernel hostname when that is empty at build time; the - "@" suffix is omitted entirely when no hostname is resolvable. + from the Claude apps. Keep it shell-safe (no spaces/quotes). This seeds + the "main" session; when null it defaults to "-main@", + where is services.agent-box.remoteControlHost (fqdnOrHostName by + default, the public sslip.io host on the AWS image), else the public + web.domain, else the live kernel hostname when both are empty at build + time; the "@" suffix is omitted when no host is resolvable. ''; }; workingDirectory = lib.mkOption { @@ -611,7 +614,17 @@ let mkStart = name: u: let home = "/home/${name}"; - fqdn = config.networking.fqdnOrHostName; + # Host suffix for auto-derived Remote Control names. Prefer an explicit + # remoteControlHost; otherwise the box's PUBLIC web domain — the name + # you actually reach the box at (sslip.io on AWS, custom DNS on bare + # metal) — which is far more useful in the Claude apps than the internal + # kernel hostname the supervisor would otherwise fall back to (an EC2 + # box's is ip-10-x-x-x..compute.internal). Guarded on web.enable + # because web.domain has no default and errors if read while unset. + hostLabel = + if cfg.remoteControlHost != "" then cfg.remoteControlHost + else if cfg.web.enable && cfg.web.domain != "" then cfg.web.domain + else ""; agentBinCases = lib.concatMapStrings (a: " ${a}) printf '%s\\n' ${lib.escapeShellArg (lib.getExe (agentPackage a))} ;;\n" ) cfg.installAgents @@ -730,24 +743,22 @@ ${agentBinCases} *) return 1 ;; fi if [ "$agent" = claude ] && [ "$rc" = true ]; then if [ -z "$rcname" ]; then - # Host suffix for the derived "[-]@" name. - # ${fqdn} is config.networking.fqdnOrHostName, fixed at build - # time — but it is "" when networking.hostName is unset, which - # used to leave a dangling trailing "@" (e.g. "agent-devs@"). - # Fall back to the live kernel hostname, and drop "@" - # entirely when even that is empty rather than emitting a bare + # Host suffix for the derived "-@" name. + # ${hostLabel} is baked at build time: remoteControlHost if set + # (the CloudFormation stack name on the AWS image), else the + # public web.domain — the address the box is actually reachable + # at. Fall back to the live kernel hostname only if both are + # empty; that kernel name is the INTERNAL fqdn on a cloud box + # (ip-10-x-x-x..compute.internal), which is why the public + # web.domain is preferred above it. Drop "@" entirely when + # even the kernel name is empty rather than emitting a dangling # "@". read is a bash builtin, so this needs nothing on PATH. - host=${fqdn} + host=${hostLabel} if [ -z "$host" ] && [ -r /proc/sys/kernel/hostname ]; then read -r host < /proc/sys/kernel/hostname || host= fi - rcbase=${name} - [ "$sname" = main ] || rcbase=${name}-$sname - if [ -n "$host" ]; then - rcname="$rcbase@$host" - else - rcname="$rcbase" - fi + rcname=${name}-$sname + [ -z "$host" ] || rcname="$rcname@$host" fi cmd="$cmd --remote-control $(printf '%q' "$rcname")" fi @@ -831,6 +842,24 @@ in ''; }; + remoteControlHost = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "my-agent-box"; + description = '' + Host label used as the "@" suffix of auto-derived Remote + Control session names (see users..sessions..remoteControlName). + Defaults to the box's fqdnOrHostName; the AWS image sets it to the + box's public sslip.io host so a box is identifiable AND reachable in + the Claude apps even when networking.hostName is unset. When empty, + the name falls back to the public web.domain (the address the box is + reachable at), and only if that is also unset to the live kernel + hostname at start time — which on a cloud box is the internal, + non-routable fqdn. + ''; + }; + users = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule userOpts); default = { }; diff --git a/modules/agent-box.nix.in b/modules/agent-box.nix.in index 750e6d0..4eb154d 100644 --- a/modules/agent-box.nix.in +++ b/modules/agent-box.nix.in @@ -234,8 +234,8 @@ let sessions = lib.mapAttrs (sname: s: { agent = if s.agent != null then s.agent else cfg.agent; inherit (s) skipPermissions remoteControl extraArgs; - # null → the supervisor derives "@" (main) or - # "-@" at start time. + # null → the supervisor derives "-@" at + # start time (see remoteControlHost). remoteControlName = s.remoteControlName; workingDirectory = if s.workingDirectory != null then s.workingDirectory @@ -410,11 +410,12 @@ let default = null; description = '' Remote Control session name. When null, defaults to - "@" for the session named "main" and - "-@" otherwise, where is - networking.fqdnOrHostName, or the live kernel hostname when that - is empty at build time. When no hostname is resolvable the "@" - suffix is omitted (just "" / "-"). + "-@", where is + services.agent-box.remoteControlHost (fqdnOrHostName by default, + the public sslip.io host on the AWS image), else the public + services.agent-box.web.domain, else the live kernel hostname when + both are empty at build time. When no host is resolvable the + "@" suffix is omitted (just "-"). ''; }; workingDirectory = lib.mkOption { @@ -501,10 +502,12 @@ let default = null; description = '' Remote Control session name, used to correlate the session to this box - from the Claude apps. Keep it shell-safe (no spaces/quotes). When null, - defaults to "@", where is networking.fqdnOrHostName, - or the live kernel hostname when that is empty at build time; the - "@" suffix is omitted entirely when no hostname is resolvable. + from the Claude apps. Keep it shell-safe (no spaces/quotes). This seeds + the "main" session; when null it defaults to "-main@", + where is services.agent-box.remoteControlHost (fqdnOrHostName by + default, the public sslip.io host on the AWS image), else the public + web.domain, else the live kernel hostname when both are empty at build + time; the "@" suffix is omitted when no host is resolvable. ''; }; workingDirectory = lib.mkOption { @@ -607,7 +610,17 @@ let mkStart = name: u: let home = "/home/${name}"; - fqdn = config.networking.fqdnOrHostName; + # Host suffix for auto-derived Remote Control names. Prefer an explicit + # remoteControlHost; otherwise the box's PUBLIC web domain — the name + # you actually reach the box at (sslip.io on AWS, custom DNS on bare + # metal) — which is far more useful in the Claude apps than the internal + # kernel hostname the supervisor would otherwise fall back to (an EC2 + # box's is ip-10-x-x-x..compute.internal). Guarded on web.enable + # because web.domain has no default and errors if read while unset. + hostLabel = + if cfg.remoteControlHost != "" then cfg.remoteControlHost + else if cfg.web.enable && cfg.web.domain != "" then cfg.web.domain + else ""; agentBinCases = lib.concatMapStrings (a: " ${a}) printf '%s\\n' ${lib.escapeShellArg (lib.getExe (agentPackage a))} ;;\n" ) cfg.installAgents @@ -726,24 +739,22 @@ ${agentBinCases} *) return 1 ;; fi if [ "$agent" = claude ] && [ "$rc" = true ]; then if [ -z "$rcname" ]; then - # Host suffix for the derived "[-]@" name. - # ${fqdn} is config.networking.fqdnOrHostName, fixed at build - # time — but it is "" when networking.hostName is unset, which - # used to leave a dangling trailing "@" (e.g. "agent-devs@"). - # Fall back to the live kernel hostname, and drop "@" - # entirely when even that is empty rather than emitting a bare + # Host suffix for the derived "-@" name. + # ${hostLabel} is baked at build time: remoteControlHost if set + # (the CloudFormation stack name on the AWS image), else the + # public web.domain — the address the box is actually reachable + # at. Fall back to the live kernel hostname only if both are + # empty; that kernel name is the INTERNAL fqdn on a cloud box + # (ip-10-x-x-x..compute.internal), which is why the public + # web.domain is preferred above it. Drop "@" entirely when + # even the kernel name is empty rather than emitting a dangling # "@". read is a bash builtin, so this needs nothing on PATH. - host=${fqdn} + host=${hostLabel} if [ -z "$host" ] && [ -r /proc/sys/kernel/hostname ]; then read -r host < /proc/sys/kernel/hostname || host= fi - rcbase=${name} - [ "$sname" = main ] || rcbase=${name}-$sname - if [ -n "$host" ]; then - rcname="$rcbase@$host" - else - rcname="$rcbase" - fi + rcname=${name}-$sname + [ -z "$host" ] || rcname="$rcname@$host" fi cmd="$cmd --remote-control $(printf '%q' "$rcname")" fi @@ -827,6 +838,24 @@ in ''; }; + remoteControlHost = lib.mkOption { + type = lib.types.str; + default = config.networking.fqdnOrHostName; + defaultText = lib.literalExpression "config.networking.fqdnOrHostName"; + example = "my-agent-box"; + description = '' + Host label used as the "@" suffix of auto-derived Remote + Control session names (see users..sessions..remoteControlName). + Defaults to the box's fqdnOrHostName; the AWS image sets it to the + box's public sslip.io host so a box is identifiable AND reachable in + the Claude apps even when networking.hostName is unset. When empty, + the name falls back to the public web.domain (the address the box is + reachable at), and only if that is also unset to the live kernel + hostname at start time — which on a cloud box is the internal, + non-routable fqdn. + ''; + }; + users = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule userOpts); default = { }; diff --git a/tests/sessions.nix b/tests/sessions.nix index f706448..3a48d38 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -38,6 +38,10 @@ services.agent-box = { enable = true; agent = "claude"; + # Leave the host label unset so auto-derived Remote Control names fall + # back to the public web.domain rather than the internal kernel + # hostname (issue: derived names showed the internal EC2 fqdn). + remoteControlHost = ""; users.agent = { web.passwordHashFile = "/var/lib/agent-box-web/password-hash"; }; @@ -123,6 +127,19 @@ "/home/agent/.config/agent-box/sessions.json" ) + # With remoteControlHost unset, the auto-derived "-@" + # Remote Control name takes its host suffix from the public web.domain, + # NOT the internal kernel hostname — and every session (including "main") + # gets the "-" suffix (no "main" special case). The supervisor + # bakes both into its start script, so assert those literals. + start_script = machine.succeed( + "systemctl show agent-box-agent --property=ExecStart --value " + "| grep -o '/nix/store/[^ ;]*-agent-box-agent-start'" + ).strip() + script_body = machine.succeed(f"cat {start_script}") + assert "host=box.test" in script_body, script_body + assert "rcname=agent-$sname" in script_body, script_body + # Both agent CLIs are installed even though no session uses codex yet # (installAgents defaults to all supported agents). machine.succeed("test -x /run/current-system/sw/bin/claude") From 8f7365bcc43b772f32c726dfd1535614866682f4 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 14:29:24 +0000 Subject: [PATCH 2/4] feat(sessions): drop the add-session name field; always auto-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add-session form's name input was already optional (blank auto-derived a name from the agent CLI). Remove the field entirely and always auto-name: users rarely care what a session is called — they rename at runtime via /rename — so an autogenerated "" / "-" is one less thing to invent, and it is the only naming path now. - Both add-session forms (the settings-page manager and the HOME tabbed workspace) lose the name ; only the agent + working-directory fields remain. - The /sessions/add handler ignores any submitted "name" and always calls gen_session_name(). Autogen guarantees a unique key, so the invalid-name 400 and the duplicate-name 409 (accidental-overwrite guard, issue 100) branches are now unreachable and are removed. - tests/sessions.nix: drive add-session by agent alone and assert the auto-derived names ("claude" when free, then a random suffix), instead of client-supplied names. modules/agent-box.nix is regenerated from the .in + src sources. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Wx9PVLmDbzS35JCN5N85i6 --- modules/agent-box.nix | 36 +++--------- modules/src/settings-daemon.py | 36 +++--------- tests/sessions.nix | 102 ++++++++++++++------------------- 3 files changed, 58 insertions(+), 116 deletions(-) diff --git a/modules/agent-box.nix b/modules/agent-box.nix index b7db061..986a09e 100644 --- a/modules/agent-box.nix +++ b/modules/agent-box.nix @@ -2431,9 +2431,6 @@ in
-
-
-
- ' in tab_page, tab_page - # A blank name auto-derives from the agent: no session is literally - # "claude" yet, so the new one lands on the bare-agent-name tab. - client.succeed( - f"{curl} -u agent:testpassword -o /dev/null -D - " - "-d 'name=&agent=claude' " - "https://box.test/sessions/add " - "| grep -i '^location: /?ok=session_added&tab=claude'" - ) - machine.wait_until_succeeds(tmux("has-session -t =claude"), timeout=60) + # Delete it (delist + kill) so "claude" is free again for later subtests. client.succeed( f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " "-d 'name=claude' " "https://box.test/sessions/delete | grep -x 303" ) - - # ...and delete it again (delist + kill). - client.succeed( - f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " - "-d 'name=web' " - "https://box.test/sessions/delete | grep -x 303" - ) machine.succeed("sleep 6") - machine.fail(tmux("has-session -t =web")) + machine.fail(tmux("has-session -t =claude")) # Session CRUD is rejected without credentials. client.succeed( f"{curl} -o /dev/null -w '%{{http_code}}' " - "-d 'name=pwn&agent=claude' " + "-d 'agent=claude' " "https://box.test/sessions/add | grep -x 401" ) @@ -395,7 +375,7 @@ # old settings-path routes remain gone)... client.succeed( f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " - "-d 'name=web2&agent=claude' " + "-d 'agent=claude' " "https://box.test/agent/settings/sessions/add | grep -x 404" ) # ...but the settings page renders the session manager again (the root @@ -450,44 +430,46 @@ assert '"ok": false' in escaped, escaped assert '"dirs": []' in escaped, escaped - # Add a session anchored in ~/work/repo: it is stored as an absolute - # path and the supervisor starts the agent in that directory. + # A non-existent directory, or one outside $HOME, is a 400 (tmux -c + # would fail on a missing cwd) and no session is created. "claude" + # was deleted above, so a rejected add must leave it absent. + for bad in ["~/nope", "/etc"]: + client.succeed( + f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " + f"-d 'agent=claude&cwd={bad}' " + "https://box.test/sessions/add | grep -x 400" + ) + machine.succeed( + "jq -e '(.sessions.claude // null) == null' " + "/home/agent/.config/agent-box/sessions.json" + ) + + # Add a session anchored in ~/work/repo: the name auto-derives to the + # bare "claude" (free again), it is stored as an absolute path, and the + # supervisor starts the agent in that directory. client.succeed( f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " - "-d 'name=wd&agent=claude&cwd=~/work/repo' " + "-d 'agent=claude&cwd=~/work/repo' " "https://box.test/sessions/add | grep -x 303" ) machine.succeed( - "jq -e '.sessions.wd.workingDirectory == \"/home/agent/work/repo\"' " + "jq -e '.sessions.claude.workingDirectory == \"/home/agent/work/repo\"' " "/home/agent/.config/agent-box/sessions.json" ) - machine.wait_until_succeeds(tmux("has-session -t =wd"), timeout=60) + machine.wait_until_succeeds(tmux("has-session -t =claude"), timeout=60) machine.wait_until_succeeds( - tmux('display -p -t "=wd:" "#{pane_current_path}"') + tmux('display -p -t "=claude:" "#{pane_current_path}"') + " | grep -x /home/agent/work/repo", timeout=60, ) - # A non-existent directory, or one outside $HOME, is a 400 (tmux -c - # would fail on a missing cwd) and no session is created. - for bad in ["~/nope", "/etc"]: - client.succeed( - f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " - f"-d 'name=wdbad&agent=claude&cwd={bad}' " - "https://box.test/sessions/add | grep -x 400" - ) - machine.succeed( - "jq -e '(.sessions.wdbad // null) == null' " - "/home/agent/.config/agent-box/sessions.json" - ) - # Clean up so the migration subtest starts from a known session set. client.succeed( f"{curl} -u agent:testpassword -o /dev/null " - "-d 'name=wd' https://box.test/sessions/delete" + "-d 'name=claude' https://box.test/sessions/delete" ) machine.succeed("sleep 6") - machine.fail(tmux("has-session -t =wd")) + machine.fail(tmux("has-session -t =claude")) # Rename migration (issue 70): re-running activation moves old-name # (claude-box) state to the agent-box paths exactly once, and never From cbfbb0262dd5f4c298db0c5a99b17aef9392291e Mon Sep 17 00:00:00 2001 From: Lio Lunesu Date: Wed, 29 Jul 2026 14:48:27 +0000 Subject: [PATCH 3/4] test(sessions): assert start-script literals in the guest, not via cat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new remote-control-name assertions cat'ed the whole supervisor start script back through the test driver's backdoor console, which wedged the driver — the sessions test hung at that exact statement until the 3600s global timeout on both CI runs of this branch. Master never streams the script body, which is why its sessions test passes in ~2 minutes. grep -F for the two literals inside the guest instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KcxC9a8wWApTSdAxKfpkGs --- tests/sessions.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/sessions.nix b/tests/sessions.nix index e72af31..73678a3 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -136,9 +136,11 @@ "systemctl show agent-box-agent --property=ExecStart --value " "| grep -o '/nix/store/[^ ;]*-agent-box-agent-start'" ).strip() - script_body = machine.succeed(f"cat {start_script}") - assert "host=box.test" in script_body, script_body - assert "rcname=agent-$sname" in script_body, script_body + # grep -F IN the guest rather than cat-ing the script body back to the + # driver: streaming the multi-KB supervisor through the backdoor console + # wedged the test driver (test hung until its 3600s global timeout, twice) + machine.succeed(f"grep -qF 'host=box.test' {start_script}") + machine.succeed(f"grep -qF 'rcname=agent-$sname' {start_script}") # Both agent CLIs are installed even though no session uses codex yet # (installAgents defaults to all supported agents). From 8f3a8ad6c1aec264f7e7289841014e8f9eb35a13 Mon Sep 17 00:00:00 2001 From: Lio Lunesu Date: Wed, 29 Jul 2026 15:34:43 +0000 Subject: [PATCH 4/4] test(sessions): take only the first ExecStart path match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `systemctl show --value` prints the store path in both path= and argv[]=, so the grep -o extraction yielded it twice. Interpolating the two-line value into later machine.succeed() calls made the second line its own shell command: the backdoor shell executed the supervisor start script as root — an infinite reconcile loop — and the test hung until timeout. head -n1 the extraction; the previous commit's cat->grep change addressed a symptom of the same bug. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KcxC9a8wWApTSdAxKfpkGs --- tests/sessions.nix | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/sessions.nix b/tests/sessions.nix index 73678a3..f18c216 100644 --- a/tests/sessions.nix +++ b/tests/sessions.nix @@ -132,13 +132,15 @@ # NOT the internal kernel hostname — and every session (including "main") # gets the "-" suffix (no "main" special case). The supervisor # bakes both into its start script, so assert those literals. + # head -n1: `systemctl show --value` prints BOTH path= and argv[]=, so the + # grep -o matches the store path twice. Without it, interpolating the + # two-line value below makes the second line its own shell command — the + # backdoor shell EXECUTES the supervisor script as root and never returns + # (the CI hang on this PR's first three runs). start_script = machine.succeed( "systemctl show agent-box-agent --property=ExecStart --value " - "| grep -o '/nix/store/[^ ;]*-agent-box-agent-start'" + "| grep -o '/nix/store/[^ ;]*-agent-box-agent-start' | head -n1" ).strip() - # grep -F IN the guest rather than cat-ing the script body back to the - # driver: streaming the multi-KB supervisor through the backdoor console - # wedged the test driver (test hung until its 3600s global timeout, twice) machine.succeed(f"grep -qF 'host=box.test' {start_script}") machine.succeed(f"grep -qF 'rcname=agent-$sname' {start_script}")