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 e83206d..b1ea226 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 = { }; @@ -2402,9 +2431,6 @@ in
-
- @" (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/modules/src/settings-daemon.py b/modules/src/settings-daemon.py index 5337cf3..32c5878 100644 --- a/modules/src/settings-daemon.py +++ b/modules/src/settings-daemon.py @@ -556,9 +556,6 @@ def change_password(previous, new):
-
- -@" + # 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. + # 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' | head -n1" + ).strip() + 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). machine.succeed("test -x /run/current-system/sw/bin/claude") @@ -311,66 +332,46 @@ assert "workingDirectory" not in root_page, root_page # The root page's CRUD routes (behind auth) can add a session; the - # workspace redirect lands on the new session's tab. Assert the raw - # Location header (h2 lowercases it, CRLF line ends — no grep -x): - # what the daemon EMITS is the contract, curl's %{redirect_url} - # resolution is not. + # workspace redirect lands on the new session's tab. The name is always + # auto-derived from the agent (there is no name field in the form): with + # no session literally named "claude" yet, the first claude add lands on + # the bare-agent-name tab. Assert the raw Location header (h2 lowercases + # it, CRLF line ends — no grep -x): what the daemon EMITS is the contract, + # curl's %{redirect_url} resolution is not. Any submitted "name" field is + # ignored, so passing a bogus one changes nothing. client.succeed( f"{curl} -u agent:testpassword -o /dev/null -D - " - "-d 'name=web&agent=claude' " + "-d 'name=ignored&agent=claude' " "https://box.test/sessions/add " - "| grep -i '^location: /?ok=session_added&tab=web'" - ) - machine.wait_until_succeeds(tmux("has-session -t =web"), timeout=60) - - # A duplicate add via the web is a 409 and the stored config survives - # (a silent overwrite would reset it to defaults — issue 100). - client.succeed( - f"{curl} -u agent:testpassword -o /dev/null -w '%{{http_code}}' " - "-d 'name=web&agent=codex' " - "https://box.test/sessions/add | grep -x 409" + "| grep -i '^location: /?ok=session_added&tab=claude'" ) + machine.wait_until_succeeds(tmux("has-session -t =claude"), timeout=60) machine.succeed( - "jq -e '.sessions.web.agent == \"claude\"' " + "jq -e '.sessions.claude.agent == \"claude\"' " "/home/agent/.config/agent-box/sessions.json" ) - # ?tab= selects a tab server-side (the no-JS switching path): the web + # ?tab= selects a tab server-side (the no-JS switching path): the new # tab is current and its live pane iframes its ttyd URL. - tab_page = client.succeed(f"{curl} -u agent:testpassword 'https://box.test/?tab=web'") - assert 'data-tab="web" href="/?tab=web" aria-current="page"' in tab_page, tab_page - assert 'src="/agent/?arg=web"' in tab_page, tab_page + tab_page = client.succeed(f"{curl} -u agent:testpassword 'https://box.test/?tab=claude'") + assert 'data-tab="claude" href="/?tab=claude" aria-current="page"' in tab_page, tab_page + assert 'src="/agent/?arg=claude"' in tab_page, tab_page # main is still a tab, just not the current one. assert 'data-tab="main" href="/?tab=main">' 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" ) @@ -378,7 +379,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 @@ -433,44 +434,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