From a47414004dd2f053ab818b93fcbb4ca483f30309 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 03:15:13 +0000 Subject: [PATCH 1/5] nextjs-ct: every Claude Code in a container uses the pool, desktop SSH sessions too The Claude desktop app's SSH sessions start their own Claude Code in the container (~/.claude/remote/ccd-cli/...) with the person's own login and no proxy. Only t-claude's launches went through `claw-master connect`, so the desktop sessions spent the person's own subscription, which the pool also spends, and the app reported "You've hit your weekly limit" while the terminal worked. - scripts/nextjs-ct/ct/claw-master-agent-setup: for an enrolled account (client.pem readable), write /etc/nextjs-ct/claw-master-agent.env, start claw-master-agent@USER, and merge the agent's HTTPS_PROXY, NODE_EXTRA_CA_CERTS and NO_PROXY (from `claw-master agent env`) into ~/.claude/settings.json "env" as the account, in one rename and only when the content changes. A settings file that is not a JSON object is left alone; a lowercase proxy spelling Claude would prefer is reported. Not enrolled: stop the agent and remove only those exact entries. - units: claw-master-agent@.service (claw-master agent --listen 127.0.0.1:8445 --server ... --dir ~/.config/claude-master, Restart=always), claw-master-agent@.path (PathChanged on client.pem) and claw-master-agent-setup@.service (setup --restart), because the agent reads its certificate once and enrolling or renewing swaps the directory. - provision: enable the path unit and run the setup for the container's account; pass --restart when it installed a new claw-master build. - scripts/ssm/claw-master-cert-install.sh: carry agent.token (a regular file, not a link) into the new directory, since connect accepts the settings only with the token beside the certificate. - AGENTS.md: a bullet on the container agent. - Tests: ClawMasterAgentTests in test-nextjs-ct-containers.py run the setup for real against fake systemctl and claw-master; the renewal test checks the token is carried over (fails without the install change). --- AGENTS.md | 10 + scripts/nextjs-ct/ct/claw-master-agent-setup | 100 ++++++++++ scripts/nextjs-ct/ct/provision | 9 +- .../ct/units/claw-master-agent-setup@.service | 6 + .../ct/units/claw-master-agent@.path | 11 ++ .../ct/units/claw-master-agent@.service | 19 ++ scripts/ssm/claw-master-cert-install.sh | 5 + scripts/test-claw-master-cert-renew.py | 20 ++ scripts/test-nextjs-ct-containers.py | 172 ++++++++++++++++++ 9 files changed, 351 insertions(+), 1 deletion(-) create mode 100755 scripts/nextjs-ct/ct/claw-master-agent-setup create mode 100644 scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service create mode 100644 scripts/nextjs-ct/ct/units/claw-master-agent@.path create mode 100644 scripts/nextjs-ct/ct/units/claw-master-agent@.service diff --git a/AGENTS.md b/AGENTS.md index 633882e0..b1e7bc17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -903,6 +903,16 @@ access token returns 401). One box therefore owns each login: `claude-master-ser request (429) until its reset, and a success clears it. There is no fallback to a login on the box: a server that is down or out of quota is an error in the session, not a quiet switch. Certificates last 30 days and renew themselves (the bullet on certificate renewal). A window running when an account is enrolled keeps plain claude until it is relaunched; the window saves the server, so a relaunch (`/clear`, `/cd`) keeps it. +- **Every Claude Code in a container uses the pool, not only t-claude's.** The Claude desktop app's SSH sessions start their own + Claude Code in the container (`~/.claude/remote/ccd-cli/...`) with the person's own login and no proxy, so they spent that + person's own subscription, which the pool also spends: the app said "You've hit your weekly limit" while the terminal worked + (2026-10-27). For an enrolled account, provision runs `claw-master-agent-setup USER` (scripts/nextjs-ct/ct): the + `claw-master-agent@USER` unit (`claw-master agent --listen 127.0.0.1:8445 --server ... --dir ~/.config/claude-master`) and its + proxy, CA and `NO_PROXY` in `~/.claude/settings.json` `env`, which every Claude Code reads; nothing else in that file changes. + `claw-master connect` accepts exactly those entries, and t-claude's sessions keep their own forwarder (connect's lowercase + `https_proxy` wins). No fallback: while the agent or the server is down those requests fail. The agent reads its certificate + once, so `claw-master-agent@USER.path` sets it up again when `client.pem` changes (enrolling, or the daily renewal, which carries + the agent's `agent.token` into the new directory). Not enrolled: no agent, and only those exact entries are removed. - **fcvm-metal-arm uses the pool the same way** (`claw-master-client.tf`): an SSM association installs the pinned client (the server's tag and sha256) and the same certificate-gated `/etc/zsh/zshenv` block, daily and once when applied, and restarts nothing. The block also exports `TCLAUDE_CLAUDE_MASTER=/usr/local/bin/claude-master` (here and on nextjs-dev): this box has a personal build in diff --git a/scripts/nextjs-ct/ct/claw-master-agent-setup b/scripts/nextjs-ct/ct/claw-master-agent-setup new file mode 100755 index 00000000..1304b5ac --- /dev/null +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -0,0 +1,100 @@ +#!/bin/bash +# claw-master-agent-setup USER [--restart] -- every Claude Code in this container uses the pool, not only the ones t-claude +# launches. +# +# t-claude starts a terminal Claude through `claw-master connect`, which gives that one process the pool's proxy. The Claude +# desktop app's SSH sessions start their own Claude Code in here (~/.claude/remote/ccd-cli/...) with the person's own login +# and no proxy, so they spent the person's own subscription, which the pool also spends: the app said "You've hit your weekly +# limit" while the terminal worked. For an ENROLLED account (its client certificate is readable) this runs the claw-master +# agent, claw-master-agent@USER (a proxy on loopback that reaches the server with the account's certificate), and sets its +# proxy, CA and loopback exemption in ~/.claude/settings.json "env", which every Claude Code reads. +# +# `claw-master connect` accepts exactly those entries (they name its own client directory's agent.token and ca.pem), and +# t-claude's sessions keep their own forwarder: connect also sets the lowercase https_proxy, which Claude prefers. No fallback +# (owner, 2026-10-18): while the agent or the server is down, those requests fail rather than go to Anthropic on the +# person's own login. +# +# Not enrolled: the agent is stopped and the entries it would set are taken out again, only when they are exactly its own. +# Nothing else in settings.json is ever changed, and the file is rewritten only when its content changes. +# +# Run by provision at every container boot, and by claw-master-agent@USER.path when the certificate is enrolled or renewed +# (--restart: the agent reads its certificate once, when it starts). The CM_AGENT_* variables are for the offline tests. +set -uo pipefail +U=${1:-} +RESTART="" +[ "${2:-}" = --restart ] && RESTART=1 +case "$U" in ''|*[!a-z0-9_-]*) echo "usage: claw-master-agent-setup USER [--restart]" >&2; exit 2 ;; esac +H=${CM_AGENT_HOME:-/home/$U} +CONFIG=${CM_AGENT_CONFIG:-/opt/nextjs-ct/config.json} +CM=${CM_AGENT_BIN:-/usr/local/bin/claw-master} +ENVFILE=${CM_AGENT_ENVFILE:-/etc/nextjs-ct/claw-master-agent.env} +LISTEN=${CM_AGENT_LISTEN:-127.0.0.1:8445} +TRIES=${CM_AGENT_TRIES:-30} # half a second each +NO_PROXY_VALUE=localhost,127.0.0.1,::1 # claw-master's agentNoProxy +DIR=$H/.config/claude-master +SETTINGS=$H/.claude/settings.json +UNIT=claw-master-agent@$U.service +as_user() { if [ "$(id -un)" = "$U" ]; then "$@"; else sudo -u "$U" -H env HOME="$H" "$@"; fi; } +say() { echo "claw-master-agent-setup: $*" >&2; } + +# Put settings.json through a jq program, as the account and in one rename, only when the result differs. A file that is +# there but is not a JSON object is left alone. +edit_settings() { + local real tmp + real=$(readlink -f "$SETTINGS" 2>/dev/null) || real=$SETTINGS + if [ -e "$real" ] && ! jq -e 'type == "object"' "$real" >/dev/null 2>&1; then + say "$SETTINGS is not a JSON object; left alone" + return 1 + fi + as_user mkdir -p "$(dirname "$real")" || return 1 + tmp=$(as_user mktemp "$(dirname "$real")/.settings.json.XXXXXX") || return 1 + if ! { if [ -e "$real" ]; then cat "$real"; else echo '{}'; fi; } | jq "$@" > "$tmp"; then + rm -f "$tmp" + say "could not update $SETTINGS" + return 1 + fi + if [ -e "$real" ] && jq -e --slurpfile new "$tmp" '. == $new[0]' "$real" >/dev/null 2>&1; then + rm -f "$tmp" + return 0 + fi + as_user mv -f "$tmp" "$real" +} + +if [ -x "$CM" ] && as_user test -r "$DIR/client.pem"; then + SERVER=$(jq -r '.claw_master.server // empty' "$CONFIG" 2>/dev/null) + [ -n "$SERVER" ] || { say "no claw_master.server in $CONFIG"; exit 1; } + install -d -m 755 "$(dirname "$ENVFILE")" && printf 'CLAW_MASTER_SERVER=%s\nCLAW_MASTER_AGENT_LISTEN=%s\n' "$SERVER" "$LISTEN" > "$ENVFILE" \ + || { say "could not write $ENVFILE"; exit 1; } + # Prints what Claude needs and makes the agent's token (agent.token beside the certificate) on first use. + LINES=$(as_user "$CM" agent env --listen "$LISTEN" --server "$SERVER" --dir "$DIR") || { say "claw-master agent env failed"; exit 1; } + ADD=$(printf '%s\n' "$LINES" | jq -Rn '[inputs | capture("^(?[A-Z_]+)=(?.+)$")] | from_entries') + [ "$(jq -c 'keys' <<<"$ADD")" = '["HTTPS_PROXY","NODE_EXTRA_CA_CERTS","NO_PROXY"]' ] || { say "unexpected agent env output"; exit 1; } + systemctl enable "$UNIT" >/dev/null 2>&1 || { say "$UNIT not enabled"; exit 1; } + if [ -n "$RESTART" ]; then systemctl restart "$UNIT"; else systemctl start "$UNIT"; fi || { say "$UNIT did not start"; exit 1; } + FAILED="" + for _ in $(seq 1 "$TRIES"); do + (exec 3<>"/dev/tcp/${LISTEN%:*}/${LISTEN##*:}") 2>/dev/null && break + sleep 0.5 + done + (exec 3<>"/dev/tcp/${LISTEN%:*}/${LISTEN##*:}") 2>/dev/null || { say "$UNIT is not listening on $LISTEN"; FAILED=1; } + # Set even when the agent is not up yet: the unit restarts it, and failing beats spending the person's own login. + edit_settings --argjson add "$ADD" '.env = ((.env | objects) // {}) + $add' || exit 1 + # Spellings Claude would take over these (it prefers the lowercase proxy names), so the agent would be bypassed. + OTHER=$(jq -r '(.env | objects) // {} | keys[] | select(. == "https_proxy" or . == "HTTP_PROXY" or . == "http_proxy" + or . == "ALL_PROXY" or . == "all_proxy" or . == "no_proxy")' "$(readlink -f "$SETTINGS")" | tr '\n' ' ') + [ -z "$OTHER" ] || { say "$SETTINGS also sets ${OTHER% }; Claude may use that instead of the pool"; FAILED=1; } + [ -z "$FAILED" ] || exit 1 + echo "claw-master-agent-setup: $U uses the pool through $LISTEN" +else + systemctl disable --now "$UNIT" >/dev/null 2>&1 || true + if [ -e "$SETTINGS" ]; then + edit_settings --arg listen "$LISTEN" --arg ca "$DIR/ca.pem" --arg np "$NO_PROXY_VALUE" ' + if ((.env | objects | .HTTPS_PROXY) // "" | strings | test("^http://claw-master:[^@/]+@" + ($listen | gsub("\\."; "\\.")) + "/?$")) + then .env |= (del(.HTTPS_PROXY) + | if .NODE_EXTRA_CA_CERTS == $ca then del(.NODE_EXTRA_CA_CERTS) else . end + | if .NO_PROXY == $np then del(.NO_PROXY) else . end) + | if .env == {} then del(.env) else . end + else . end' || exit 1 + fi + echo "claw-master-agent-setup: $U is not enrolled; no agent" +fi diff --git a/scripts/nextjs-ct/ct/provision b/scripts/nextjs-ct/ct/provision index b9db3291..4867d835 100644 --- a/scripts/nextjs-ct/ct/provision +++ b/scripts/nextjs-ct/ct/provision @@ -123,11 +123,13 @@ fi # claw-master client, pinned with the server (claw-master-server.tf), and the claude-master link t-claude runs. CM_TAG=$(cfg .claw_master.tag); CM_SHA=$(cfg .claw_master.sha256) +CM_UPDATED="" if [ "$(uname -m)" = aarch64 ] && [ "$(sha256sum /usr/local/bin/claw-master 2>/dev/null | cut -d' ' -f1)" != "$CM_SHA" ]; then T=$(mktemp -d) if curl -fsSL --retry 3 "https://github.com/ejc3/CLIProxyAPI/releases/download/$CM_TAG/claw-master-linux-arm64" -o "$T/cm" \ && echo "$CM_SHA $T/cm" | sha256sum -c --quiet -; then - install -m 0755 "$T/cm" /usr/local/bin/claw-master.new && mv -f /usr/local/bin/claw-master.new /usr/local/bin/claw-master + install -m 0755 "$T/cm" /usr/local/bin/claw-master.new && mv -f /usr/local/bin/claw-master.new /usr/local/bin/claw-master \ + && CM_UPDATED=1 else warn "pinned claw-master did not download or verify" fi @@ -281,6 +283,11 @@ as_user() { sudo -u "$U" -H env HOME="$H" "$@"; } as_user bash -c '[ -x "$HOME/.local/bin/claude" ] || curl -fsSL https://claude.ai/install.sh | bash' >/dev/null 2>&1 \ || warn "claude install failed for $U" as_user bash -c 'grep -q "HOME/.local/bin" ~/.zshrc 2>/dev/null || printf "%s\n" "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> ~/.zshrc' +# Every Claude Code here through the pool, the desktop app's SSH sessions too, not only t-claude's (claw-master-agent-setup +# says why). The path unit sets it up again when the certificate is enrolled or renewed; a new claw-master build restarts it. +systemctl enable "claw-master-agent@$U.path" >/dev/null 2>&1 && systemctl start "claw-master-agent@$U.path" \ + || warn "claw-master-agent@$U.path not enabled" +/usr/local/bin/claw-master-agent-setup "$U" ${CM_UPDATED:+--restart} || warn "claw-master agent setup failed for $U" [ -x "$H/.codex/packages/standalone/current/codex" ] \ || as_user sh -c "cd $H && curl -fsSL https://chatgpt.com/codex/install.sh | sh" >/dev/null 2>&1 || warn "codex install failed for $U" diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service new file mode 100644 index 00000000..9899b778 --- /dev/null +++ b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service @@ -0,0 +1,6 @@ +[Unit] +Description=Set up the claw-master agent for %i (its client certificate changed) + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/claw-master-agent-setup %i --restart diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent@.path b/scripts/nextjs-ct/ct/units/claw-master-agent@.path new file mode 100644 index 00000000..b9830da1 --- /dev/null +++ b/scripts/nextjs-ct/ct/units/claw-master-agent@.path @@ -0,0 +1,11 @@ +[Unit] +# The agent reads its certificate once, when it starts. Enrolling swaps a new ~/.config/claude-master in, and so does the daily +# renewal (claw-master-cert-renew.tf, which never restarts anything itself): either one sets the agent up again. +Description=Set up the claw-master agent again for %i when its client certificate changes + +[Path] +PathChanged=/home/%i/.config/claude-master/client.pem +Unit=claw-master-agent-setup@%i.service + +[Install] +WantedBy=multi-user.target diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent@.service b/scripts/nextjs-ct/ct/units/claw-master-agent@.service new file mode 100644 index 00000000..7cbb2112 --- /dev/null +++ b/scripts/nextjs-ct/ct/units/claw-master-agent@.service @@ -0,0 +1,19 @@ +[Unit] +# claw-master-agent-setup enables it for an enrolled account and writes the environment file; see that script for why. +Description=claw-master agent for %i: the pool's proxy for every Claude Code in this container +After=network-online.target +Wants=network-online.target +ConditionPathExists=/home/%i/.config/claude-master/client.pem +# Keeps trying while the server is away: each try is cheap, and the agent only exits on a fault it cannot serve through. +StartLimitIntervalSec=0 + +[Service] +User=%i +Environment=HOME=/home/%i +EnvironmentFile=/etc/nextjs-ct/claw-master-agent.env +ExecStart=/usr/local/bin/claw-master agent --listen ${CLAW_MASTER_AGENT_LISTEN} --server ${CLAW_MASTER_SERVER} --dir /home/%i/.config/claude-master +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/scripts/ssm/claw-master-cert-install.sh b/scripts/ssm/claw-master-cert-install.sh index d7a159bb..fbeb4133 100755 --- a/scripts/ssm/claw-master-cert-install.sh +++ b/scripts/ssm/claw-master-cert-install.sh @@ -25,6 +25,11 @@ CN=$(openssl x509 -noout -subject -nameopt RFC2253 -in "$WORK/client.pem" 2>/dev openssl x509 -checkend $((MIN_DAYS * 86400)) -noout -in "$WORK/client.pem" >/dev/null 2>&1 || { echo "INSTALL=expires-too-soon"; exit 1; } install -m 0644 "$WORK/client.pem" "$NEW/client.pem" && install -m 0644 "$WORK/ca.pem" "$NEW/ca.pem" || { echo "INSTALL=write-failed"; exit 1; } if [ "$(id -u)" = 0 ]; then chown "$ACCOUNT": "$NEW/client.pem" "$NEW/ca.pem" || { echo "INSTALL=chown-failed"; exit 1; }; fi +# The claw-master agent's token (nextjs-ct's claw-master-agent-setup) lives beside the certificate, where `claw-master connect` +# looks for it. It belongs to the account, not to the certificate: without it connect refuses the settings that name it. +if [ -f "$DIR/agent.token" ] && [ ! -L "$DIR/agent.token" ]; then + cp -p "$DIR/agent.token" "$NEW/agent.token" || { echo "INSTALL=token-copy-failed"; exit 1; } +fi rm -rf "$OLD" if [ -d "$DIR" ]; then mv "$DIR" "$OLD" || { echo "INSTALL=swap-failed"; exit 1; }; fi if ! mv "$NEW" "$DIR"; then [ -d "$OLD" ] && mv "$OLD" "$DIR"; echo "INSTALL=swap-failed"; exit 1; fi diff --git a/scripts/test-claw-master-cert-renew.py b/scripts/test-claw-master-cert-renew.py index 0b1c2f3c..67ab5307 100644 --- a/scripts/test-claw-master-cert-renew.py +++ b/scripts/test-claw-master-cert-renew.py @@ -254,6 +254,26 @@ def test_install_swaps_a_verified_certificate_in_and_leaves_no_litter(self): self.assertEqual(oct((live / "client.key").stat().st_mode & 0o777), "0o600") self.assertEqual(oct((live / "client.pem").stat().st_mode & 0o777), "0o644") + def test_install_carries_the_agent_token_over(self): + """The agent's token sits beside the certificate, where `claw-master connect` reads it to accept the settings that name + it (nextjs-ct's claw-master-agent-setup); a renewal that dropped it would make connect refuse every launch.""" + home = self.home(days=3) + token = home / ".config/claude-master/agent.token" + token.write_text("TOKEN\n") + token.chmod(0o600) + _, cert = self.pending(home, days=30) + r = self.install(home, cert) + self.assertEqual(kv(r.stdout).get("INSTALL"), "ok", r.stdout + r.stderr) + carried = home / ".config/claude-master/agent.token" + self.assertEqual(carried.read_text(), "TOKEN\n") + self.assertEqual(oct(carried.stat().st_mode & 0o777), "0o600") + # a link is not carried: the token must be a private regular file, and connect refuses anything else anyway + home2 = self.home(days=3) + (home2 / ".config/claude-master/agent.token").symlink_to("/etc/hostname") + _, cert2 = self.pending(home2, days=30) + self.assertEqual(kv(self.install(home2, cert2).stdout).get("INSTALL"), "ok") + self.assertFalse((home2 / ".config/claude-master/agent.token").exists()) + def test_install_works_when_there_is_no_live_directory_yet(self): home = self.home(days=None) _, cert = self.pending(home) diff --git a/scripts/test-nextjs-ct-containers.py b/scripts/test-nextjs-ct-containers.py index 10ee944f..4aa0a6cd 100644 --- a/scripts/test-nextjs-ct-containers.py +++ b/scripts/test-nextjs-ct-containers.py @@ -823,6 +823,178 @@ def test_container_ndev_register_refuses_another_account(self): +class ClawMasterAgentTests(unittest.TestCase): + """Every Claude Code in a container reaches the pool, the desktop app's SSH sessions too (they start their own Claude Code + with the person's own login, which spent that person's own subscription): claw-master-agent-setup runs the agent for an + enrolled account and sets its three entries in ~/.claude/settings.json. Runs for real against fake systemctl and claw-master.""" + + SETUP = CT / "claw-master-agent-setup" + UNITS = CT / "units" + TOKEN = "TESTTOKEN" + + def world(self, enrolled=True, listening=True): + d = Path(tempfile.mkdtemp()) + self.addCleanup(subprocess.run, ["rm", "-rf", str(d)]) + home, bin_ = d / "home", d / "bin" + bin_.mkdir() + (home / ".config/claude-master").mkdir(parents=True) + if enrolled: + (home / ".config/claude-master/client.pem").write_text("cert") + fake(bin_, "systemctl", 'echo "$*" >> "%s"\n' % (d / "systemctl.log")) + cm = fake(bin_, "claw-master", 'echo "$*" >> "%s"\n' % (d / "cm.log") + + '[ "$1 $2" = "agent env" ] || exit 9\n' + 'echo "HTTPS_PROXY=http://claw-master:%s@$4"\n' + 'echo "NODE_EXTRA_CA_CERTS=$8/ca.pem"\n' + 'echo "NO_PROXY=localhost,127.0.0.1,::1"\n' % self.TOKEN) + (d / "config.json").write_text(json.dumps({"claw_master": {"server": "192.0.2.10:8443"}})) + port = 0 + if listening: + srv = socket.socket() + srv.bind(("127.0.0.1", 0)) + srv.listen(16) + self.addCleanup(srv.close) + port = srv.getsockname()[1] + else: + with socket.socket() as s: # a port nothing listens on + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + env = {"PATH": "%s:/usr/bin:/bin" % bin_, "CM_AGENT_HOME": str(home), "CM_AGENT_CONFIG": str(d / "config.json"), + "CM_AGENT_BIN": str(cm), "CM_AGENT_ENVFILE": str(d / "etc/claw-master-agent.env"), + "CM_AGENT_LISTEN": "127.0.0.1:%d" % port, "CM_AGENT_TRIES": "2"} + return types_ns(d=d, home=home, env=env, port=port, settings=home / ".claude/settings.json") + + def run_setup(self, w, *extra): + user = subprocess.run(["id", "-un"], capture_output=True, text=True, check=True).stdout.strip() + return subprocess.run(["bash", str(self.SETUP), user, *extra], env=w.env, capture_output=True, text=True, timeout=60) + + def ours(self, w): + return {"HTTPS_PROXY": "http://claw-master:%s@127.0.0.1:%d" % (self.TOKEN, w.port), + "NODE_EXTRA_CA_CERTS": "%s/.config/claude-master/ca.pem" % w.home, "NO_PROXY": "localhost,127.0.0.1,::1"} + + def calls(self, w): + log = w.d / "systemctl.log" + return log.read_text().splitlines() if log.exists() else [] + + def test_an_enrolled_account_gets_the_agent_and_its_settings(self): + w = self.world() + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(w.settings.read_text()), {"env": self.ours(w)}) + self.assertEqual(oct(w.settings.stat().st_mode & 0o777), "0o600", "the proxy URL carries the agent's token") + user = subprocess.run(["id", "-un"], capture_output=True, text=True).stdout.strip() + self.assertEqual(self.calls(w), ["enable claw-master-agent@%s.service" % user, "start claw-master-agent@%s.service" % user]) + self.assertEqual((w.d / "etc/claw-master-agent.env").read_text(), + "CLAW_MASTER_SERVER=192.0.2.10:8443\nCLAW_MASTER_AGENT_LISTEN=127.0.0.1:%d\n" % w.port) + self.assertIn("agent env --listen 127.0.0.1:%d --server 192.0.2.10:8443 --dir %s/.config/claude-master" % (w.port, w.home), + (w.d / "cm.log").read_text()) + + def test_the_rest_of_settings_is_kept_and_an_unchanged_file_is_not_rewritten(self): + w = self.world() + w.settings.parent.mkdir() + w.settings.write_text(json.dumps({"theme": "dark", "hooks": {"Stop": []}, "env": {"FOO": "1"}})) + self.assertEqual(self.run_setup(w).returncode, 0) + want = {"theme": "dark", "hooks": {"Stop": []}, "env": dict(FOO="1", **self.ours(w))} + self.assertEqual(json.loads(w.settings.read_text()), want) + before = w.settings.stat() + self.assertEqual(self.run_setup(w).returncode, 0) + after = w.settings.stat() + self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns), + "Claude Code reloads a changed settings file: a run that changes nothing must not touch it") + self.assertEqual(list(w.settings.parent.glob(".settings.json.*")), [], "no temporary file is left behind") + + def test_a_symlinked_settings_file_is_edited_where_it_points(self): + w = self.world() + w.settings.parent.mkdir() + target = w.home / "dotfiles-settings.json" + target.write_text("{}") + w.settings.symlink_to(target) + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertTrue(w.settings.is_symlink()) + self.assertEqual(json.loads(target.read_text()), {"env": self.ours(w)}) + + def test_a_certificate_change_restarts_the_agent(self): + w = self.world() + self.assertEqual(self.run_setup(w, "--restart").returncode, 0) + self.assertTrue(self.calls(w)[-1].startswith("restart claw-master-agent@"), self.calls(w)) + + def test_settings_that_are_not_a_json_object_are_left_alone_and_fail(self): + w = self.world() + w.settings.parent.mkdir() + for text in ("{not json", "[1, 2]"): + w.settings.write_text(text) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0) + self.assertEqual(w.settings.read_text(), text) + self.assertIn("not a JSON object", r.stderr) + + def test_a_proxy_spelling_claude_prefers_is_reported(self): + w = self.world() + w.settings.parent.mkdir() + w.settings.write_text(json.dumps({"env": {"https_proxy": "http://elsewhere:1"}})) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0) + self.assertIn("also sets https_proxy", r.stderr) + self.assertEqual(json.loads(w.settings.read_text())["env"]["https_proxy"], "http://elsewhere:1", "never removed: not ours") + + def test_an_agent_that_does_not_come_up_fails_but_still_keeps_claude_off_the_persons_login(self): + w = self.world(listening=False) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0) + self.assertIn("is not listening", r.stderr) + self.assertEqual(json.loads(w.settings.read_text()), {"env": self.ours(w)}) + + def test_an_account_that_is_not_enrolled_has_no_agent_and_only_its_entries_are_removed(self): + w = self.world() + w.settings.parent.mkdir() + w.settings.write_text(json.dumps({"theme": "dark", "env": dict(FOO="1", **self.ours(w))})) + (w.home / ".config/claude-master/client.pem").unlink() + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(w.settings.read_text()), {"theme": "dark", "env": {"FOO": "1"}}) + self.assertTrue(self.calls(w)[-1].startswith("disable --now claw-master-agent@"), self.calls(w)) + # with nothing else in env, env goes too + w.settings.write_text(json.dumps({"env": self.ours(w)})) + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertEqual(json.loads(w.settings.read_text()), {}) + # someone else's proxy is never touched + theirs = {"env": {"HTTPS_PROXY": "http://corp-proxy:3128", "NO_PROXY": "localhost,127.0.0.1,::1"}} + w.settings.write_text(json.dumps(theirs)) + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertEqual(json.loads(w.settings.read_text()), theirs) + # and no settings file is not created + w.settings.unlink() + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertFalse(w.settings.exists()) + + def test_the_units_run_the_agent_and_set_it_up_again_when_the_certificate_changes(self): + agent = (self.UNITS / "claw-master-agent@.service").read_text() + self.assertIn("User=%i\n", agent) + self.assertIn("EnvironmentFile=/etc/nextjs-ct/claw-master-agent.env\n", agent) + self.assertIn("ExecStart=/usr/local/bin/claw-master agent --listen ${CLAW_MASTER_AGENT_LISTEN} " + "--server ${CLAW_MASTER_SERVER} --dir /home/%i/.config/claude-master\n", agent) + self.assertIn("Restart=always\n", agent) + path = (self.UNITS / "claw-master-agent@.path").read_text() + self.assertIn("PathChanged=/home/%i/.config/claude-master/client.pem\n", path) + self.assertIn("Unit=claw-master-agent-setup@%i.service\n", path) + oneshot = (self.UNITS / "claw-master-agent-setup@.service").read_text() + self.assertIn("ExecStart=/usr/local/bin/claw-master-agent-setup %i --restart\n", oneshot) + setup = self.SETUP.read_text() + self.assertIn('ENVFILE=${CM_AGENT_ENVFILE:-/etc/nextjs-ct/claw-master-agent.env}', setup) + self.assertIn("LISTEN=${CM_AGENT_LISTEN:-127.0.0.1:8445}", setup) + + def test_provision_sets_up_the_agent_for_its_account_and_restarts_it_for_a_new_build(self): + prov = (CT / "provision").read_text() + self.assertIn('systemctl enable "claw-master-agent@$U.path" >/dev/null 2>&1 && systemctl start "claw-master-agent@$U.path"', prov) + self.assertIn('/usr/local/bin/claw-master-agent-setup "$U" ${CM_UPDATED:+--restart} || warn', prov) + self.assertLess(prov.index('CM_UPDATED=""'), prov.index("&& CM_UPDATED=1")) + self.assertLess(prov.index("&& CM_UPDATED=1"), prov.index("claw-master-agent-setup \"$U\"")) + self.assertLess(prov.index('U=$(cat "$ME/user")'), prov.index("claw-master-agent-setup \"$U\"")) + + +def types_ns(**kw): + import types + return types.SimpleNamespace(**kw) + class NoContainerRestartTests(unittest.TestCase): """A re-run of setup (any change to the published script) must not restart the containers. Every systemd-nspawn@ unit Requires= the firewall and its home mount, and restarting a required unit restarts the From 883f3565ad01751da19a16dd13355300e9160a18 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 03:28:26 +0000 Subject: [PATCH 2/5] nextjs-ct agent: a managed settings drop-in, restart on a newer certificate, one run at a time Repairs from review of the first commit: - claw-master-agent-setup writes the agent's HTTPS_PROXY, NODE_EXTRA_CA_CERTS and NO_PROXY to /etc/claude-code/managed-settings.d/50-claw-master-agent.json (whole file, one rename, mode 640 with the person's group) instead of editing ~/.claude/settings.json: managed settings outrank user and project settings, a concurrent save to the person's file can no longer be lost, and a jq program can no longer empty it. connect validates drop-ins with the same allowance. - Enrolment is the certificate alone: a missing claw-master or bad `agent env` output fails and keeps the drop-in. A certificate missing for a few seconds (a renewal's two renames) is waited out, not taken as un-enrolment. - The agent is restarted when client.pem is newer than the running agent (or with --restart), so the path unit and a new hourly claw-master-agent@.timer can both run the setup; provision enables both. - Ready means the unit's MainPID owns the listener on the port (ss -p), not any listener. - A flock serializes runs: enabling the timer starts one at once, which raced provision's run on the environment file in a live test. - Tests rewritten for the drop-in, with real `ss`, a concurrent-runs case and the transient-absence case; AGENTS.md updated. --- AGENTS.md | 13 +- scripts/nextjs-ct/ct/claw-master-agent-setup | 131 +++++----- scripts/nextjs-ct/ct/provision | 8 +- .../ct/units/claw-master-agent-setup@.service | 4 +- .../ct/units/claw-master-agent@.path | 3 +- .../ct/units/claw-master-agent@.timer | 10 + scripts/test-nextjs-ct-containers.py | 238 ++++++++++-------- 7 files changed, 226 insertions(+), 181 deletions(-) create mode 100644 scripts/nextjs-ct/ct/units/claw-master-agent@.timer diff --git a/AGENTS.md b/AGENTS.md index b1e7bc17..3c6035c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -908,11 +908,14 @@ access token returns 401). One box therefore owns each login: `claude-master-ser person's own subscription, which the pool also spends: the app said "You've hit your weekly limit" while the terminal worked (2026-10-27). For an enrolled account, provision runs `claw-master-agent-setup USER` (scripts/nextjs-ct/ct): the `claw-master-agent@USER` unit (`claw-master agent --listen 127.0.0.1:8445 --server ... --dir ~/.config/claude-master`) and its - proxy, CA and `NO_PROXY` in `~/.claude/settings.json` `env`, which every Claude Code reads; nothing else in that file changes. - `claw-master connect` accepts exactly those entries, and t-claude's sessions keep their own forwarder (connect's lowercase - `https_proxy` wins). No fallback: while the agent or the server is down those requests fail. The agent reads its certificate - once, so `claw-master-agent@USER.path` sets it up again when `client.pem` changes (enrolling, or the daily renewal, which carries - the agent's `agent.token` into the new directory). Not enrolled: no agent, and only those exact entries are removed. + proxy, CA and `NO_PROXY` in the managed settings drop-in `/etc/claude-code/managed-settings.d/50-claw-master-agent.json` + (mode 640, the person's group: it carries the agent's token), which every Claude Code here reads above user and project + settings; the person's own `~/.claude/settings.json` is never touched. `claw-master connect` accepts exactly those entries, and + t-claude's sessions keep their own forwarder (connect's lowercase `https_proxy` wins). No fallback: while the agent or the + server is down those requests fail, and an enrolled account's fault keeps the drop-in. The agent reads its certificate once, + so it is restarted when `client.pem` is newer than it: `claw-master-agent@USER.path` on a change and `.timer` hourly (a + renewal swaps the directory, and carries the agent's `agent.token` into the new one). A certificate missing for a few seconds + is a swap, not un-enrolment; one that stays missing means no agent and no drop-in. - **fcvm-metal-arm uses the pool the same way** (`claw-master-client.tf`): an SSM association installs the pinned client (the server's tag and sha256) and the same certificate-gated `/etc/zsh/zshenv` block, daily and once when applied, and restarts nothing. The block also exports `TCLAUDE_CLAUDE_MASTER=/usr/local/bin/claude-master` (here and on nextjs-dev): this box has a personal build in diff --git a/scripts/nextjs-ct/ct/claw-master-agent-setup b/scripts/nextjs-ct/ct/claw-master-agent-setup index 1304b5ac..13f35a46 100755 --- a/scripts/nextjs-ct/ct/claw-master-agent-setup +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -6,19 +6,20 @@ # desktop app's SSH sessions start their own Claude Code in here (~/.claude/remote/ccd-cli/...) with the person's own login # and no proxy, so they spent the person's own subscription, which the pool also spends: the app said "You've hit your weekly # limit" while the terminal worked. For an ENROLLED account (its client certificate is readable) this runs the claw-master -# agent, claw-master-agent@USER (a proxy on loopback that reaches the server with the account's certificate), and sets its -# proxy, CA and loopback exemption in ~/.claude/settings.json "env", which every Claude Code reads. +# agent, claw-master-agent@USER (a proxy on loopback that reaches the server with the account's certificate), and puts its +# proxy, CA and loopback exemption in a managed settings drop-in, which every Claude Code here reads and which outranks the +# person's own and any project's settings. The container is one person's, so a machine-wide file is theirs alone; the +# person's ~/.claude/settings.json is never touched. # # `claw-master connect` accepts exactly those entries (they name its own client directory's agent.token and ca.pem), and # t-claude's sessions keep their own forwarder: connect also sets the lowercase https_proxy, which Claude prefers. No fallback # (owner, 2026-10-18): while the agent or the server is down, those requests fail rather than go to Anthropic on the # person's own login. # -# Not enrolled: the agent is stopped and the entries it would set are taken out again, only when they are exactly its own. -# Nothing else in settings.json is ever changed, and the file is rewritten only when its content changes. -# -# Run by provision at every container boot, and by claw-master-agent@USER.path when the certificate is enrolled or renewed -# (--restart: the agent reads its certificate once, when it starts). The CM_AGENT_* variables are for the offline tests. +# Not enrolled: no agent and no drop-in. Run by provision at every container boot (--restart after it installed a new +# claw-master), by claw-master-agent@USER.path when the certificate changes and hourly by claw-master-agent@USER.timer. The +# agent reads its certificate once, when it starts, so it is restarted whenever client.pem is newer than the running agent +# (enrolling and the daily renewal both swap a new directory in). The CM_AGENT_* variables are for the offline tests. set -uo pipefail U=${1:-} RESTART="" @@ -28,73 +29,77 @@ H=${CM_AGENT_HOME:-/home/$U} CONFIG=${CM_AGENT_CONFIG:-/opt/nextjs-ct/config.json} CM=${CM_AGENT_BIN:-/usr/local/bin/claw-master} ENVFILE=${CM_AGENT_ENVFILE:-/etc/nextjs-ct/claw-master-agent.env} +DROPIN=${CM_AGENT_DROPIN:-/etc/claude-code/managed-settings.d/50-claw-master-agent.json} LISTEN=${CM_AGENT_LISTEN:-127.0.0.1:8445} TRIES=${CM_AGENT_TRIES:-30} # half a second each -NO_PROXY_VALUE=localhost,127.0.0.1,::1 # claw-master's agentNoProxy DIR=$H/.config/claude-master -SETTINGS=$H/.claude/settings.json +PEM=$DIR/client.pem UNIT=claw-master-agent@$U.service +LOCK=${CM_AGENT_LOCK:-/run/claw-master-agent-setup.lock} as_user() { if [ "$(id -un)" = "$U" ]; then "$@"; else sudo -u "$U" -H env HOME="$H" "$@"; fi; } say() { echo "claw-master-agent-setup: $*" >&2; } -# Put settings.json through a jq program, as the account and in one rename, only when the result differs. A file that is -# there but is not a JSON object is left alone. -edit_settings() { - local real tmp - real=$(readlink -f "$SETTINGS" 2>/dev/null) || real=$SETTINGS - if [ -e "$real" ] && ! jq -e 'type == "object"' "$real" >/dev/null 2>&1; then - say "$SETTINGS is not a JSON object; left alone" - return 1 - fi - as_user mkdir -p "$(dirname "$real")" || return 1 - tmp=$(as_user mktemp "$(dirname "$real")/.settings.json.XXXXXX") || return 1 - if ! { if [ -e "$real" ]; then cat "$real"; else echo '{}'; fi; } | jq "$@" > "$tmp"; then - rm -f "$tmp" - say "could not update $SETTINGS" - return 1 - fi - if [ -e "$real" ] && jq -e --slurpfile new "$tmp" '. == $new[0]' "$real" >/dev/null 2>&1; then - rm -f "$tmp" - return 0 - fi - as_user mv -f "$tmp" "$real" -} +# One run at a time: provision, the path unit and the timer can all start one at once (enabling the timer starts it). +exec 9>"$LOCK" && flock 9 || { say "cannot take $LOCK"; exit 1; } -if [ -x "$CM" ] && as_user test -r "$DIR/client.pem"; then - SERVER=$(jq -r '.claw_master.server // empty' "$CONFIG" 2>/dev/null) - [ -n "$SERVER" ] || { say "no claw_master.server in $CONFIG"; exit 1; } - install -d -m 755 "$(dirname "$ENVFILE")" && printf 'CLAW_MASTER_SERVER=%s\nCLAW_MASTER_AGENT_LISTEN=%s\n' "$SERVER" "$LISTEN" > "$ENVFILE" \ - || { say "could not write $ENVFILE"; exit 1; } - # Prints what Claude needs and makes the agent's token (agent.token beside the certificate) on first use. - LINES=$(as_user "$CM" agent env --listen "$LISTEN" --server "$SERVER" --dir "$DIR") || { say "claw-master agent env failed"; exit 1; } - ADD=$(printf '%s\n' "$LINES" | jq -Rn '[inputs | capture("^(?[A-Z_]+)=(?.+)$")] | from_entries') - [ "$(jq -c 'keys' <<<"$ADD")" = '["HTTPS_PROXY","NODE_EXTRA_CA_CERTS","NO_PROXY"]' ] || { say "unexpected agent env output"; exit 1; } - systemctl enable "$UNIT" >/dev/null 2>&1 || { say "$UNIT not enabled"; exit 1; } - if [ -n "$RESTART" ]; then systemctl restart "$UNIT"; else systemctl start "$UNIT"; fi || { say "$UNIT did not start"; exit 1; } - FAILED="" +# A renewal swaps the directory with two renames, so the certificate can be missing for a moment: only an absence that +# lasts is "not enrolled". +enrolled() { + local _ for _ in $(seq 1 "$TRIES"); do - (exec 3<>"/dev/tcp/${LISTEN%:*}/${LISTEN##*:}") 2>/dev/null && break + as_user test -r "$PEM" && return 0 sleep 0.5 done - (exec 3<>"/dev/tcp/${LISTEN%:*}/${LISTEN##*:}") 2>/dev/null || { say "$UNIT is not listening on $LISTEN"; FAILED=1; } - # Set even when the agent is not up yet: the unit restarts it, and failing beats spending the person's own login. - edit_settings --argjson add "$ADD" '.env = ((.env | objects) // {}) + $add' || exit 1 - # Spellings Claude would take over these (it prefers the lowercase proxy names), so the agent would be bypassed. - OTHER=$(jq -r '(.env | objects) // {} | keys[] | select(. == "https_proxy" or . == "HTTP_PROXY" or . == "http_proxy" - or . == "ALL_PROXY" or . == "all_proxy" or . == "no_proxy")' "$(readlink -f "$SETTINGS")" | tr '\n' ' ') - [ -z "$OTHER" ] || { say "$SETTINGS also sets ${OTHER% }; Claude may use that instead of the pool"; FAILED=1; } - [ -z "$FAILED" ] || exit 1 - echo "claw-master-agent-setup: $U uses the pool through $LISTEN" -else + as_user test -r "$PEM" +} + +if ! enrolled; then systemctl disable --now "$UNIT" >/dev/null 2>&1 || true - if [ -e "$SETTINGS" ]; then - edit_settings --arg listen "$LISTEN" --arg ca "$DIR/ca.pem" --arg np "$NO_PROXY_VALUE" ' - if ((.env | objects | .HTTPS_PROXY) // "" | strings | test("^http://claw-master:[^@/]+@" + ($listen | gsub("\\."; "\\.")) + "/?$")) - then .env |= (del(.HTTPS_PROXY) - | if .NODE_EXTRA_CA_CERTS == $ca then del(.NODE_EXTRA_CA_CERTS) else . end - | if .NO_PROXY == $np then del(.NO_PROXY) else . end) - | if .env == {} then del(.env) else . end - else . end' || exit 1 - fi + rm -f "$DROPIN" echo "claw-master-agent-setup: $U is not enrolled; no agent" + exit 0 fi + +# Enrolled from here on: a fault keeps whatever drop-in is there, so Claude fails rather than use the person's own login. +[ -x "$CM" ] || { say "$CM is missing"; exit 1; } +SERVER=$(jq -r '.claw_master.server // empty' "$CONFIG" 2>/dev/null) +[ -n "$SERVER" ] || { say "no claw_master.server in $CONFIG"; exit 1; } +install -d -m 755 "$(dirname "$ENVFILE")" && printf 'CLAW_MASTER_SERVER=%s\nCLAW_MASTER_AGENT_LISTEN=%s\n' "$SERVER" "$LISTEN" > "$ENVFILE.new" \ + && mv -f "$ENVFILE.new" "$ENVFILE" || { say "could not write $ENVFILE"; exit 1; } +# Prints what Claude needs, and makes the agent's token (agent.token beside the certificate) on first use. +LINES=$(as_user "$CM" agent env --listen "$LISTEN" --server "$SERVER" --dir "$DIR") || { say "claw-master agent env failed"; exit 1; } +SETTINGS=$(printf '%s\n' "$LINES" | jq -Rn '{env: ([inputs | capture("^(?[A-Z_]+)=(?.+)$")] | from_entries)}') \ + && [ "$(jq -c '.env | keys' <<<"$SETTINGS")" = '["HTTPS_PROXY","NODE_EXTRA_CA_CERTS","NO_PROXY"]' ] \ + || { say "unexpected claw-master agent env output"; exit 1; } + +systemctl enable "$UNIT" >/dev/null 2>&1 || { say "$UNIT not enabled"; exit 1; } +if systemctl is-active --quiet "$UNIT"; then + STARTED=$(date -d "$(systemctl show -p ActiveEnterTimestamp --value "$UNIT")" +%s 2>/dev/null || echo 0) + if [ -n "$RESTART" ] || [ "$(stat -c %Y "$PEM")" -gt "$STARTED" ]; then + systemctl restart "$UNIT" || { say "$UNIT did not restart"; exit 1; } + fi +else + systemctl start "$UNIT" || { say "$UNIT did not start"; exit 1; } +fi + +# The drop-in is written whether or not the agent came up: the unit keeps restarting it, and failing beats spending the +# person's own login. It carries the agent's token, so only the person (and root) can read it. +install -d -m 755 "$(dirname "$DROPIN")" || { say "cannot make $(dirname "$DROPIN")"; exit 1; } +if ! [ -f "$DROPIN" ] || [ "$(cat "$DROPIN")" != "$SETTINGS" ]; then + TMP=$(mktemp "$(dirname "$DROPIN")/.claw-master-agent.XXXXXX") && printf '%s\n' "$SETTINGS" > "$TMP" \ + && chgrp "$(id -gn "$U")" "$TMP" && chmod 640 "$TMP" && mv -f "$TMP" "$DROPIN" \ + || { rm -f "${TMP:-}"; say "could not write $DROPIN"; exit 1; } +fi + +# Ready means the unit's own process listens there: something else on the port would be handed every Claude request. +PORT=${LISTEN##*:} +for _ in $(seq 1 "$TRIES"); do + PID=$(systemctl show -p MainPID --value "$UNIT") + [ "${PID:-0}" != 0 ] && ss -Hltnp "sport = :$PORT" 2>/dev/null | grep -q "pid=$PID," && { + echo "claw-master-agent-setup: $U uses the pool through $LISTEN" + exit 0 + } + sleep 0.5 +done +say "$UNIT is not the listener on $LISTEN" +exit 1 diff --git a/scripts/nextjs-ct/ct/provision b/scripts/nextjs-ct/ct/provision index 4867d835..c2c97be8 100644 --- a/scripts/nextjs-ct/ct/provision +++ b/scripts/nextjs-ct/ct/provision @@ -284,9 +284,11 @@ as_user bash -c '[ -x "$HOME/.local/bin/claude" ] || curl -fsSL https://claude.a || warn "claude install failed for $U" as_user bash -c 'grep -q "HOME/.local/bin" ~/.zshrc 2>/dev/null || printf "%s\n" "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> ~/.zshrc' # Every Claude Code here through the pool, the desktop app's SSH sessions too, not only t-claude's (claw-master-agent-setup -# says why). The path unit sets it up again when the certificate is enrolled or renewed; a new claw-master build restarts it. -systemctl enable "claw-master-agent@$U.path" >/dev/null 2>&1 && systemctl start "claw-master-agent@$U.path" \ - || warn "claw-master-agent@$U.path not enabled" +# says why). The path unit and an hourly timer set it up again when the certificate is enrolled or renewed; a new +# claw-master build restarts it. +for t in "claw-master-agent@$U.path" "claw-master-agent@$U.timer"; do + systemctl enable --now "$t" >/dev/null 2>&1 || warn "$t not enabled" +done /usr/local/bin/claw-master-agent-setup "$U" ${CM_UPDATED:+--restart} || warn "claw-master agent setup failed for $U" [ -x "$H/.codex/packages/standalone/current/codex" ] \ || as_user sh -c "cd $H && curl -fsSL https://chatgpt.com/codex/install.sh | sh" >/dev/null 2>&1 || warn "codex install failed for $U" diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service index 9899b778..906c75b1 100644 --- a/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service +++ b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service @@ -1,6 +1,6 @@ [Unit] -Description=Set up the claw-master agent for %i (its client certificate changed) +Description=Set up the claw-master agent for %i (restarted when its client certificate is newer than it) [Service] Type=oneshot -ExecStart=/usr/local/bin/claw-master-agent-setup %i --restart +ExecStart=/usr/local/bin/claw-master-agent-setup %i diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent@.path b/scripts/nextjs-ct/ct/units/claw-master-agent@.path index b9830da1..00019fad 100644 --- a/scripts/nextjs-ct/ct/units/claw-master-agent@.path +++ b/scripts/nextjs-ct/ct/units/claw-master-agent@.path @@ -1,6 +1,7 @@ [Unit] # The agent reads its certificate once, when it starts. Enrolling swaps a new ~/.config/claude-master in, and so does the daily -# renewal (claw-master-cert-renew.tf, which never restarts anything itself): either one sets the agent up again. +# renewal (claw-master-cert-renew.tf, which never restarts anything itself): either one sets the agent up again. A directory +# swap is not a write to the file, so claw-master-agent@.timer also runs the setup hourly. Description=Set up the claw-master agent again for %i when its client certificate changes [Path] diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent@.timer b/scripts/nextjs-ct/ct/units/claw-master-agent@.timer new file mode 100644 index 00000000..2d28b45f --- /dev/null +++ b/scripts/nextjs-ct/ct/units/claw-master-agent@.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check the claw-master agent for %i hourly (a renewed certificate restarts it) + +[Timer] +OnBootSec=10min +OnUnitActiveSec=1h +Unit=claw-master-agent-setup@%i.service + +[Install] +WantedBy=timers.target diff --git a/scripts/test-nextjs-ct-containers.py b/scripts/test-nextjs-ct-containers.py index 4aa0a6cd..0cc17417 100644 --- a/scripts/test-nextjs-ct-containers.py +++ b/scripts/test-nextjs-ct-containers.py @@ -14,6 +14,7 @@ import tempfile import threading import time +import types import unittest ROOT = Path(__file__).resolve().parent.parent @@ -826,28 +827,37 @@ def test_container_ndev_register_refuses_another_account(self): class ClawMasterAgentTests(unittest.TestCase): """Every Claude Code in a container reaches the pool, the desktop app's SSH sessions too (they start their own Claude Code with the person's own login, which spent that person's own subscription): claw-master-agent-setup runs the agent for an - enrolled account and sets its three entries in ~/.claude/settings.json. Runs for real against fake systemctl and claw-master.""" + enrolled account and puts its three entries in a managed settings drop-in. Runs for real against a fake systemctl and a + fake claw-master; `ss` is real, so "ready" is checked against an actual listening socket and its owner.""" SETUP = CT / "claw-master-agent-setup" UNITS = CT / "units" TOKEN = "TESTTOKEN" + USER = subprocess.run(["id", "-un"], capture_output=True, text=True, check=True).stdout.strip() + GROUP = subprocess.run(["id", "-gn"], capture_output=True, text=True, check=True).stdout.strip() - def world(self, enrolled=True, listening=True): + def world(self, enrolled=True, listening=True, owner_is_unit=True, env_output=None): d = Path(tempfile.mkdtemp()) self.addCleanup(subprocess.run, ["rm", "-rf", str(d)]) - home, bin_ = d / "home", d / "bin" - bin_.mkdir() - (home / ".config/claude-master").mkdir(parents=True) + home, bin_, state = d / "home", d / "bin", d / "state" + for x in (bin_, state, home / ".config/claude-master"): + x.mkdir(parents=True) if enrolled: (home / ".config/claude-master/client.pem").write_text("cert") - fake(bin_, "systemctl", 'echo "$*" >> "%s"\n' % (d / "systemctl.log")) - cm = fake(bin_, "claw-master", 'echo "$*" >> "%s"\n' % (d / "cm.log") + - '[ "$1 $2" = "agent env" ] || exit 9\n' - 'echo "HTTPS_PROXY=http://claw-master:%s@$4"\n' - 'echo "NODE_EXTRA_CA_CERTS=$8/ca.pem"\n' - 'echo "NO_PROXY=localhost,127.0.0.1,::1"\n' % self.TOKEN) + fake(bin_, "systemctl", f"""echo "$*" >> "{d}/systemctl.log" +S="{state}" +case "$1" in + is-active) [ -e "$S/active" ] ;; + show) case "$3" in ActiveEnterTimestamp) cat "$S/started_at" 2>/dev/null || echo ;; MainPID) cat "$S/mainpid" 2>/dev/null || echo 0 ;; esac ;; + start|restart) touch "$S/active"; date '+%a %Y-%m-%d %H:%M:%S %Z' > "$S/started_at" ;; + disable) rm -f "$S/active" ;; +esac +""") + out = env_output if env_output is not None else ( + 'echo "HTTPS_PROXY=http://claw-master:%s@$4"\necho "NODE_EXTRA_CA_CERTS=$8/ca.pem"\n' + 'echo "NO_PROXY=localhost,127.0.0.1,::1"\n' % self.TOKEN) + cm = fake(bin_, "claw-master", 'echo "$*" >> "%s"\n[ "$1 $2" = "agent env" ] || exit 9\n%s' % (d / "cm.log", out)) (d / "config.json").write_text(json.dumps({"claw_master": {"server": "192.0.2.10:8443"}})) - port = 0 if listening: srv = socket.socket() srv.bind(("127.0.0.1", 0)) @@ -855,145 +865,159 @@ def world(self, enrolled=True, listening=True): self.addCleanup(srv.close) port = srv.getsockname()[1] else: - with socket.socket() as s: # a port nothing listens on - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - env = {"PATH": "%s:/usr/bin:/bin" % bin_, "CM_AGENT_HOME": str(home), "CM_AGENT_CONFIG": str(d / "config.json"), - "CM_AGENT_BIN": str(cm), "CM_AGENT_ENVFILE": str(d / "etc/claw-master-agent.env"), - "CM_AGENT_LISTEN": "127.0.0.1:%d" % port, "CM_AGENT_TRIES": "2"} - return types_ns(d=d, home=home, env=env, port=port, settings=home / ".claude/settings.json") + with socket.socket() as sock: # a port nothing listens on + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + # The unit's main process: this test process owns the listening socket, so its pid is the right answer. + (state / "mainpid").write_text(str(os.getpid() if owner_is_unit else 1)) + env = {"PATH": "%s:/usr/bin:/bin:/usr/sbin:/sbin" % bin_, "CM_AGENT_HOME": str(home), + "CM_AGENT_CONFIG": str(d / "config.json"), "CM_AGENT_BIN": str(cm), + "CM_AGENT_ENVFILE": str(d / "etc/claw-master-agent.env"), + "CM_AGENT_DROPIN": str(d / "etc/claude-code/managed-settings.d/50-claw-master-agent.json"), + "CM_AGENT_LISTEN": "127.0.0.1:%d" % port, "CM_AGENT_TRIES": "4", "CM_AGENT_LOCK": str(d / "setup.lock")} + return types.SimpleNamespace(d=d, home=home, env=env, port=port, state=state, pem=home / ".config/claude-master/client.pem", + dropin=Path(env["CM_AGENT_DROPIN"]), log=d / "systemctl.log") def run_setup(self, w, *extra): - user = subprocess.run(["id", "-un"], capture_output=True, text=True, check=True).stdout.strip() - return subprocess.run(["bash", str(self.SETUP), user, *extra], env=w.env, capture_output=True, text=True, timeout=60) + return subprocess.run(["bash", str(self.SETUP), self.USER, *extra], env=w.env, capture_output=True, text=True, timeout=60) def ours(self, w): - return {"HTTPS_PROXY": "http://claw-master:%s@127.0.0.1:%d" % (self.TOKEN, w.port), - "NODE_EXTRA_CA_CERTS": "%s/.config/claude-master/ca.pem" % w.home, "NO_PROXY": "localhost,127.0.0.1,::1"} + return {"env": {"HTTPS_PROXY": "http://claw-master:%s@127.0.0.1:%d" % (self.TOKEN, w.port), + "NODE_EXTRA_CA_CERTS": "%s/.config/claude-master/ca.pem" % w.home, + "NO_PROXY": "localhost,127.0.0.1,::1"}} - def calls(self, w): - log = w.d / "systemctl.log" - return log.read_text().splitlines() if log.exists() else [] + def calls(self, w, verb=None): + lines = w.log.read_text().splitlines() if w.log.exists() else [] + return [l for l in lines if verb is None or l.split()[0] == verb] - def test_an_enrolled_account_gets_the_agent_and_its_settings(self): + def test_an_enrolled_account_gets_the_agent_and_a_managed_drop_in(self): w = self.world() + settings = w.home / ".claude/settings.json" + settings.parent.mkdir() + settings.write_text('{"theme": "dark"}') r = self.run_setup(w) self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual(json.loads(w.settings.read_text()), {"env": self.ours(w)}) - self.assertEqual(oct(w.settings.stat().st_mode & 0o777), "0o600", "the proxy URL carries the agent's token") - user = subprocess.run(["id", "-un"], capture_output=True, text=True).stdout.strip() - self.assertEqual(self.calls(w), ["enable claw-master-agent@%s.service" % user, "start claw-master-agent@%s.service" % user]) + self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w)) + st = w.dropin.stat() + self.assertEqual(oct(st.st_mode & 0o777), "0o640", "the proxy URL carries the agent's token") + self.assertEqual(subprocess.run(["stat", "-c", "%G", str(w.dropin)], capture_output=True, text=True).stdout.strip(), self.GROUP) + unit = "claw-master-agent@%s.service" % self.USER + self.assertEqual(self.calls(w, "enable") + self.calls(w, "start"), ["enable " + unit, "start " + unit]) self.assertEqual((w.d / "etc/claw-master-agent.env").read_text(), "CLAW_MASTER_SERVER=192.0.2.10:8443\nCLAW_MASTER_AGENT_LISTEN=127.0.0.1:%d\n" % w.port) self.assertIn("agent env --listen 127.0.0.1:%d --server 192.0.2.10:8443 --dir %s/.config/claude-master" % (w.port, w.home), (w.d / "cm.log").read_text()) + self.assertEqual(settings.read_text(), '{"theme": "dark"}', "the person's own settings are never touched") + self.assertEqual(list(w.dropin.parent.glob(".*")), [], "no temporary file is left behind") - def test_the_rest_of_settings_is_kept_and_an_unchanged_file_is_not_rewritten(self): + def test_a_second_run_changes_nothing_and_restarts_nothing(self): w = self.world() - w.settings.parent.mkdir() - w.settings.write_text(json.dumps({"theme": "dark", "hooks": {"Stop": []}, "env": {"FOO": "1"}})) - self.assertEqual(self.run_setup(w).returncode, 0) - want = {"theme": "dark", "hooks": {"Stop": []}, "env": dict(FOO="1", **self.ours(w))} - self.assertEqual(json.loads(w.settings.read_text()), want) - before = w.settings.stat() + os.utime(w.pem, (time.time() - 3600, time.time() - 3600)) self.assertEqual(self.run_setup(w).returncode, 0) - after = w.settings.stat() + before = w.dropin.stat() + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + after = w.dropin.stat() self.assertEqual((before.st_ino, before.st_mtime_ns), (after.st_ino, after.st_mtime_ns), "Claude Code reloads a changed settings file: a run that changes nothing must not touch it") - self.assertEqual(list(w.settings.parent.glob(".settings.json.*")), [], "no temporary file is left behind") + self.assertEqual(self.calls(w, "restart"), []) + self.assertEqual(len(self.calls(w, "start")), 1) - def test_a_symlinked_settings_file_is_edited_where_it_points(self): + def test_a_certificate_newer_than_the_agent_restarts_it_and_so_does_a_new_build(self): w = self.world() - w.settings.parent.mkdir() - target = w.home / "dotfiles-settings.json" - target.write_text("{}") - w.settings.symlink_to(target) + os.utime(w.pem, (time.time() - 3600, time.time() - 3600)) self.assertEqual(self.run_setup(w).returncode, 0) - self.assertTrue(w.settings.is_symlink()) - self.assertEqual(json.loads(target.read_text()), {"env": self.ours(w)}) + (w.state / "started_at").write_text(time.strftime("%a %Y-%m-%d %H:%M:%S UTC", time.gmtime(time.time() - 600)) + "\n") + os.utime(w.pem) # renewed after the agent started + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertEqual(len(self.calls(w, "restart")), 1) + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertEqual(len(self.calls(w, "restart")), 1, "once restarted, the same certificate restarts nothing") + self.assertEqual(self.run_setup(w, "--restart").returncode, 0) + self.assertEqual(len(self.calls(w, "restart")), 2) - def test_a_certificate_change_restarts_the_agent(self): + def test_runs_started_together_take_turns(self): + """Provision, the path unit and the timer can start a run at the same moment (enabling the timer starts one).""" w = self.world() - self.assertEqual(self.run_setup(w, "--restart").returncode, 0) - self.assertTrue(self.calls(w)[-1].startswith("restart claw-master-agent@"), self.calls(w)) + procs = [subprocess.Popen(["bash", str(self.SETUP), self.USER], env=w.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True) for _ in range(4)] + results = [(p.wait(timeout=60), p.stderr.read()) for p in procs] + for p in procs: + p.stdout.close(); p.stderr.close() + self.assertEqual([rc for rc, _ in results], [0, 0, 0, 0], results) + self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w)) + + def test_a_certificate_missing_for_a_moment_is_not_unenrollment(self): + """A renewal swaps the directory with two renames; a run that lands between them must wait, not tear down.""" + w = self.world(enrolled=False) + threading.Timer(0.6, lambda: w.pem.write_text("cert")).start() + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(self.calls(w, "disable"), []) + self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w)) - def test_settings_that_are_not_a_json_object_are_left_alone_and_fail(self): + def test_an_account_that_is_not_enrolled_has_no_agent_and_no_drop_in(self): w = self.world() - w.settings.parent.mkdir() - for text in ("{not json", "[1, 2]"): - w.settings.write_text(text) - r = self.run_setup(w) - self.assertNotEqual(r.returncode, 0) - self.assertEqual(w.settings.read_text(), text) - self.assertIn("not a JSON object", r.stderr) + self.assertEqual(self.run_setup(w).returncode, 0) + w.pem.unlink() + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertFalse(w.dropin.exists()) + self.assertEqual(self.calls(w, "disable"), ["disable --now claw-master-agent@%s.service" % self.USER]) - def test_a_proxy_spelling_claude_prefers_is_reported(self): + def test_an_enrolled_account_with_a_fault_keeps_its_drop_in(self): + """Every fault fails and leaves the drop-in in place: Claude then fails instead of using the person's own login.""" w = self.world() - w.settings.parent.mkdir() - w.settings.write_text(json.dumps({"env": {"https_proxy": "http://elsewhere:1"}})) + self.assertEqual(self.run_setup(w).returncode, 0) + want = w.dropin.read_text() + cm = Path(w.env["CM_AGENT_BIN"]) + cm.rename(cm.with_name("gone")) r = self.run_setup(w) self.assertNotEqual(r.returncode, 0) - self.assertIn("also sets https_proxy", r.stderr) - self.assertEqual(json.loads(w.settings.read_text())["env"]["https_proxy"], "http://elsewhere:1", "never removed: not ours") - - def test_an_agent_that_does_not_come_up_fails_but_still_keeps_claude_off_the_persons_login(self): - w = self.world(listening=False) + self.assertIn("is missing", r.stderr) + self.assertEqual((w.dropin.read_text(), self.calls(w, "disable")), (want, [])) + cm.with_name("gone").rename(cm) + cm.write_text('#!/bin/bash\necho "HTTPS_PROXY=http://elsewhere:1"\n') r = self.run_setup(w) self.assertNotEqual(r.returncode, 0) - self.assertIn("is not listening", r.stderr) - self.assertEqual(json.loads(w.settings.read_text()), {"env": self.ours(w)}) + self.assertIn("unexpected claw-master agent env output", r.stderr) + self.assertEqual(w.dropin.read_text(), want) - def test_an_account_that_is_not_enrolled_has_no_agent_and_only_its_entries_are_removed(self): - w = self.world() - w.settings.parent.mkdir() - w.settings.write_text(json.dumps({"theme": "dark", "env": dict(FOO="1", **self.ours(w))})) - (w.home / ".config/claude-master/client.pem").unlink() - r = self.run_setup(w) - self.assertEqual(r.returncode, 0, r.stderr) - self.assertEqual(json.loads(w.settings.read_text()), {"theme": "dark", "env": {"FOO": "1"}}) - self.assertTrue(self.calls(w)[-1].startswith("disable --now claw-master-agent@"), self.calls(w)) - # with nothing else in env, env goes too - w.settings.write_text(json.dumps({"env": self.ours(w)})) - self.assertEqual(self.run_setup(w).returncode, 0) - self.assertEqual(json.loads(w.settings.read_text()), {}) - # someone else's proxy is never touched - theirs = {"env": {"HTTPS_PROXY": "http://corp-proxy:3128", "NO_PROXY": "localhost,127.0.0.1,::1"}} - w.settings.write_text(json.dumps(theirs)) - self.assertEqual(self.run_setup(w).returncode, 0) - self.assertEqual(json.loads(w.settings.read_text()), theirs) - # and no settings file is not created - w.settings.unlink() - self.assertEqual(self.run_setup(w).returncode, 0) - self.assertFalse(w.settings.exists()) + def test_ready_means_the_units_own_process_listens(self): + for why, kw in (("nothing listens", {"listening": False}), ("another process listens", {"owner_is_unit": False})): + w = self.world(**kw) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0, why) + self.assertIn("is not the listener", r.stderr, why) + self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w), why + ": the drop-in still keeps Claude off the person's login") - def test_the_units_run_the_agent_and_set_it_up_again_when_the_certificate_changes(self): + def test_the_units_run_the_agent_and_set_it_up_again(self): agent = (self.UNITS / "claw-master-agent@.service").read_text() - self.assertIn("User=%i\n", agent) - self.assertIn("EnvironmentFile=/etc/nextjs-ct/claw-master-agent.env\n", agent) - self.assertIn("ExecStart=/usr/local/bin/claw-master agent --listen ${CLAW_MASTER_AGENT_LISTEN} " - "--server ${CLAW_MASTER_SERVER} --dir /home/%i/.config/claude-master\n", agent) - self.assertIn("Restart=always\n", agent) + for line in ("User=%i", "EnvironmentFile=/etc/nextjs-ct/claw-master-agent.env", "Restart=always", + "ExecStart=/usr/local/bin/claw-master agent --listen ${CLAW_MASTER_AGENT_LISTEN} " + "--server ${CLAW_MASTER_SERVER} --dir /home/%i/.config/claude-master"): + self.assertIn(line + "\n", agent) path = (self.UNITS / "claw-master-agent@.path").read_text() self.assertIn("PathChanged=/home/%i/.config/claude-master/client.pem\n", path) self.assertIn("Unit=claw-master-agent-setup@%i.service\n", path) + timer = (self.UNITS / "claw-master-agent@.timer").read_text() + self.assertIn("OnUnitActiveSec=1h\n", timer) + self.assertIn("Unit=claw-master-agent-setup@%i.service\n", timer) oneshot = (self.UNITS / "claw-master-agent-setup@.service").read_text() - self.assertIn("ExecStart=/usr/local/bin/claw-master-agent-setup %i --restart\n", oneshot) + self.assertIn("ExecStart=/usr/local/bin/claw-master-agent-setup %i\n", oneshot) setup = self.SETUP.read_text() - self.assertIn('ENVFILE=${CM_AGENT_ENVFILE:-/etc/nextjs-ct/claw-master-agent.env}', setup) - self.assertIn("LISTEN=${CM_AGENT_LISTEN:-127.0.0.1:8445}", setup) + for default in ("ENVFILE=${CM_AGENT_ENVFILE:-/etc/nextjs-ct/claw-master-agent.env}", + "DROPIN=${CM_AGENT_DROPIN:-/etc/claude-code/managed-settings.d/50-claw-master-agent.json}", + "LISTEN=${CM_AGENT_LISTEN:-127.0.0.1:8445}"): + self.assertIn(default, setup) def test_provision_sets_up_the_agent_for_its_account_and_restarts_it_for_a_new_build(self): prov = (CT / "provision").read_text() - self.assertIn('systemctl enable "claw-master-agent@$U.path" >/dev/null 2>&1 && systemctl start "claw-master-agent@$U.path"', prov) + self.assertIn('for t in "claw-master-agent@$U.path" "claw-master-agent@$U.timer"; do', prov) self.assertIn('/usr/local/bin/claw-master-agent-setup "$U" ${CM_UPDATED:+--restart} || warn', prov) self.assertLess(prov.index('CM_UPDATED=""'), prov.index("&& CM_UPDATED=1")) - self.assertLess(prov.index("&& CM_UPDATED=1"), prov.index("claw-master-agent-setup \"$U\"")) - self.assertLess(prov.index('U=$(cat "$ME/user")'), prov.index("claw-master-agent-setup \"$U\"")) - - -def types_ns(**kw): - import types - return types.SimpleNamespace(**kw) + self.assertLess(prov.index("&& CM_UPDATED=1"), prov.index('claw-master-agent-setup "$U"')) + self.assertLess(prov.index('U=$(cat "$ME/user")'), prov.index('claw-master-agent-setup "$U"')) class NoContainerRestartTests(unittest.TestCase): """A re-run of setup (any change to the published script) must not restart the containers. Every From 34833b6b3ad4e2c361721a5d2b11b9c471bc029b Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 03:34:00 +0000 Subject: [PATCH 3/5] nextjs-ct agent: restart on a different certificate by sha256; report lowercase proxy settings - claw-master-agent-setup records the sha256 of client.pem the agent was started with (/run/claw-master-agent@USER.pem.sha256, taken before the start) and restarts the agent when the current certificate differs. Whole-second mtimes missed a renewal replaced in the agent's start second or staged before it and swapped in after. - It reports (and fails on) https_proxy, http_proxy, HTTP_PROXY, all_proxy, ALL_PROXY or no_proxy in the person's ~/.claude/settings.json env: Claude prefers them over the drop-in's uppercase entries, which the drop-in cannot override because connect accepts only those three. The file is never edited. - Tests: a certificate with new content and an older mtime restarts the agent; each conflicting spelling is reported with the drop-in kept. Both fail on the previous commit. AGENTS.md and the unit description updated. --- AGENTS.md | 5 ++-- scripts/nextjs-ct/ct/claw-master-agent-setup | 24 +++++++++++------ .../ct/units/claw-master-agent-setup@.service | 2 +- scripts/test-nextjs-ct-containers.py | 26 ++++++++++++++----- 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c6035c7..df031cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -913,9 +913,10 @@ access token returns 401). One box therefore owns each login: `claude-master-ser settings; the person's own `~/.claude/settings.json` is never touched. `claw-master connect` accepts exactly those entries, and t-claude's sessions keep their own forwarder (connect's lowercase `https_proxy` wins). No fallback: while the agent or the server is down those requests fail, and an enrolled account's fault keeps the drop-in. The agent reads its certificate once, - so it is restarted when `client.pem` is newer than it: `claw-master-agent@USER.path` on a change and `.timer` hourly (a + so it is restarted when `client.pem` is not the certificate it started with (by sha256): `claw-master-agent@USER.path` on a change and `.timer` hourly (a renewal swaps the directory, and carries the agent's `agent.token` into the new one). A certificate missing for a few seconds - is a swap, not un-enrolment; one that stays missing means no agent and no drop-in. + is a swap, not un-enrolment; one that stays missing means no agent and no drop-in. A lowercase `https_proxy` or `no_proxy` (which Claude + prefers) in the person's own settings is reported, since the drop-in cannot override it; one in a project's settings is not seen. - **fcvm-metal-arm uses the pool the same way** (`claw-master-client.tf`): an SSM association installs the pinned client (the server's tag and sha256) and the same certificate-gated `/etc/zsh/zshenv` block, daily and once when applied, and restarts nothing. The block also exports `TCLAUDE_CLAUDE_MASTER=/usr/local/bin/claude-master` (here and on nextjs-dev): this box has a personal build in diff --git a/scripts/nextjs-ct/ct/claw-master-agent-setup b/scripts/nextjs-ct/ct/claw-master-agent-setup index 13f35a46..03de015d 100755 --- a/scripts/nextjs-ct/ct/claw-master-agent-setup +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -18,8 +18,8 @@ # # Not enrolled: no agent and no drop-in. Run by provision at every container boot (--restart after it installed a new # claw-master), by claw-master-agent@USER.path when the certificate changes and hourly by claw-master-agent@USER.timer. The -# agent reads its certificate once, when it starts, so it is restarted whenever client.pem is newer than the running agent -# (enrolling and the daily renewal both swap a new directory in). The CM_AGENT_* variables are for the offline tests. +# agent reads its certificate once, when it starts, so it is restarted whenever client.pem is not the certificate it was +# started with (enrolling and the daily renewal both swap a new directory in). The CM_AGENT_* variables are for the offline tests. set -uo pipefail U=${1:-} RESTART="" @@ -36,6 +36,8 @@ DIR=$H/.config/claude-master PEM=$DIR/client.pem UNIT=claw-master-agent@$U.service LOCK=${CM_AGENT_LOCK:-/run/claw-master-agent-setup.lock} +LOADED=${CM_AGENT_LOADED:-/run/claw-master-agent@$U.pem.sha256} # the certificate the running agent was started with +USER_SETTINGS=$H/.claude/settings.json as_user() { if [ "$(id -un)" = "$U" ]; then "$@"; else sudo -u "$U" -H env HOME="$H" "$@"; fi; } say() { echo "claw-master-agent-setup: $*" >&2; } @@ -73,14 +75,14 @@ SETTINGS=$(printf '%s\n' "$LINES" | jq -Rn '{env: ([inputs | capture("^(?[A || { say "unexpected claw-master agent env output"; exit 1; } systemctl enable "$UNIT" >/dev/null 2>&1 || { say "$UNIT not enabled"; exit 1; } -if systemctl is-active --quiet "$UNIT"; then - STARTED=$(date -d "$(systemctl show -p ActiveEnterTimestamp --value "$UNIT")" +%s 2>/dev/null || echo 0) - if [ -n "$RESTART" ] || [ "$(stat -c %Y "$PEM")" -gt "$STARTED" ]; then - systemctl restart "$UNIT" || { say "$UNIT did not restart"; exit 1; } - fi -else +# Taken before the agent starts, so a certificate swapped in meanwhile reads as different next time and restarts it again. +SUM=$(as_user sha256sum "$PEM" | cut -d' ' -f1) && [ -n "$SUM" ] || { say "cannot read $PEM"; exit 1; } +if ! systemctl is-active --quiet "$UNIT"; then systemctl start "$UNIT" || { say "$UNIT did not start"; exit 1; } +elif [ -n "$RESTART" ] || [ "$(cat "$LOADED" 2>/dev/null)" != "$SUM" ]; then + systemctl restart "$UNIT" || { say "$UNIT did not restart"; exit 1; } fi +echo "$SUM" > "$LOADED" || { say "cannot write $LOADED"; exit 1; } # The drop-in is written whether or not the agent came up: the unit keeps restarting it, and failing beats spending the # person's own login. It carries the agent's token, so only the person (and root) can read it. @@ -91,6 +93,12 @@ if ! [ -f "$DROPIN" ] || [ "$(cat "$DROPIN")" != "$SETTINGS" ]; then || { rm -f "${TMP:-}"; say "could not write $DROPIN"; exit 1; } fi +# Spellings Claude takes over HTTPS_PROXY and NO_PROXY (it prefers lowercase), set in the person's own settings, would route +# around the agent; the drop-in cannot set them (connect accepts only the three it writes), so they are reported. +OTHER=$(jq -r '(.env | objects) // {} | keys[] | select(IN("https_proxy", "http_proxy", "HTTP_PROXY", "all_proxy", "ALL_PROXY", "no_proxy"))' \ + "$USER_SETTINGS" 2>/dev/null | tr '\n' ' ') +[ -z "$OTHER" ] || { say "$USER_SETTINGS sets ${OTHER% }, which Claude would use instead of the agent"; exit 1; } + # Ready means the unit's own process listens there: something else on the port would be handed every Claude request. PORT=${LISTEN##*:} for _ in $(seq 1 "$TRIES"); do diff --git a/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service index 906c75b1..ed8300d9 100644 --- a/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service +++ b/scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service @@ -1,5 +1,5 @@ [Unit] -Description=Set up the claw-master agent for %i (restarted when its client certificate is newer than it) +Description=Set up the claw-master agent for %i (restarted when its client certificate is not the one it runs with) [Service] Type=oneshot diff --git a/scripts/test-nextjs-ct-containers.py b/scripts/test-nextjs-ct-containers.py index 0cc17417..7f3fef88 100644 --- a/scripts/test-nextjs-ct-containers.py +++ b/scripts/test-nextjs-ct-containers.py @@ -874,7 +874,8 @@ def world(self, enrolled=True, listening=True, owner_is_unit=True, env_output=No "CM_AGENT_CONFIG": str(d / "config.json"), "CM_AGENT_BIN": str(cm), "CM_AGENT_ENVFILE": str(d / "etc/claw-master-agent.env"), "CM_AGENT_DROPIN": str(d / "etc/claude-code/managed-settings.d/50-claw-master-agent.json"), - "CM_AGENT_LISTEN": "127.0.0.1:%d" % port, "CM_AGENT_TRIES": "4", "CM_AGENT_LOCK": str(d / "setup.lock")} + "CM_AGENT_LISTEN": "127.0.0.1:%d" % port, "CM_AGENT_TRIES": "4", "CM_AGENT_LOCK": str(d / "setup.lock"), + "CM_AGENT_LOADED": str(d / "loaded.sha256")} return types.SimpleNamespace(d=d, home=home, env=env, port=port, state=state, pem=home / ".config/claude-master/client.pem", dropin=Path(env["CM_AGENT_DROPIN"]), log=d / "systemctl.log") @@ -912,7 +913,6 @@ def test_an_enrolled_account_gets_the_agent_and_a_managed_drop_in(self): def test_a_second_run_changes_nothing_and_restarts_nothing(self): w = self.world() - os.utime(w.pem, (time.time() - 3600, time.time() - 3600)) self.assertEqual(self.run_setup(w).returncode, 0) before = w.dropin.stat() r = self.run_setup(w) @@ -923,12 +923,14 @@ def test_a_second_run_changes_nothing_and_restarts_nothing(self): self.assertEqual(self.calls(w, "restart"), []) self.assertEqual(len(self.calls(w, "start")), 1) - def test_a_certificate_newer_than_the_agent_restarts_it_and_so_does_a_new_build(self): + def test_a_different_certificate_restarts_the_agent_and_so_does_a_new_build(self): w = self.world() - os.utime(w.pem, (time.time() - 3600, time.time() - 3600)) + stamp = (time.time() - 3600, time.time() - 3600) + os.utime(w.pem, stamp) self.assertEqual(self.run_setup(w).returncode, 0) - (w.state / "started_at").write_text(time.strftime("%a %Y-%m-%d %H:%M:%S UTC", time.gmtime(time.time() - 600)) + "\n") - os.utime(w.pem) # renewed after the agent started + # Renewed with an OLDER timestamp than the running agent (staged before it started, swapped in after): content counts. + w.pem.write_text("renewed cert") + os.utime(w.pem, stamp) self.assertEqual(self.run_setup(w).returncode, 0) self.assertEqual(len(self.calls(w, "restart")), 1) self.assertEqual(self.run_setup(w).returncode, 0) @@ -936,6 +938,18 @@ def test_a_certificate_newer_than_the_agent_restarts_it_and_so_does_a_new_build( self.assertEqual(self.run_setup(w, "--restart").returncode, 0) self.assertEqual(len(self.calls(w, "restart")), 2) + def test_a_lowercase_proxy_in_the_persons_settings_is_reported(self): + for key in ("https_proxy", "no_proxy", "ALL_PROXY"): + w = self.world() + settings = w.home / ".claude/settings.json" + settings.parent.mkdir() + settings.write_text(json.dumps({"env": {key: "http://proxy.example.com:3128"}})) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0, key) + self.assertIn("sets %s, which Claude would use instead of the agent" % key, r.stderr) + self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w), "the drop-in is still in place") + self.assertEqual(json.loads(settings.read_text()), {"env": {key: "http://proxy.example.com:3128"}}, "never edited") + def test_runs_started_together_take_turns(self): """Provision, the path unit and the timer can start a run at the same moment (enabling the timer starts one).""" w = self.world() From 92d042e8e378f88df0cf07019b74025312d1f86f Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 04:16:45 +0000 Subject: [PATCH 4/5] Fence direct routing before first claw-master agent setup --- scripts/nextjs-ct/ct/claw-master-agent-setup | 8 ++++++++ scripts/test-nextjs-ct-containers.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/scripts/nextjs-ct/ct/claw-master-agent-setup b/scripts/nextjs-ct/ct/claw-master-agent-setup index 03de015d..b97e57b7 100755 --- a/scripts/nextjs-ct/ct/claw-master-agent-setup +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -63,6 +63,14 @@ if ! enrolled; then fi # Enrolled from here on: a fault keeps whatever drop-in is there, so Claude fails rather than use the person's own login. +# On the first run there is nothing to keep yet. Fence direct routing before invoking the client or starting its unit; +# connect rejects this sentinel too, since it has no agent token. Validated agent settings replace it below. +if ! [ -f "$DROPIN" ]; then + install -d -m 755 "$(dirname "$DROPIN")" && TMP=$(mktemp "$(dirname "$DROPIN")/.claw-master-agent.XXXXXX") \ + && jq -n --arg ca "$DIR/ca.pem" '{env: {HTTPS_PROXY: "http://127.0.0.1:1", NODE_EXTRA_CA_CERTS: $ca, NO_PROXY: "localhost,127.0.0.1,::1"}}' > "$TMP" \ + && chgrp "$(id -gn "$U")" "$TMP" && chmod 640 "$TMP" && mv -f "$TMP" "$DROPIN" \ + || { rm -f "${TMP:-}"; say "could not fence direct routing in $DROPIN"; exit 1; } +fi [ -x "$CM" ] || { say "$CM is missing"; exit 1; } SERVER=$(jq -r '.claw_master.server // empty' "$CONFIG" 2>/dev/null) [ -n "$SERVER" ] || { say "no claw_master.server in $CONFIG"; exit 1; } diff --git a/scripts/test-nextjs-ct-containers.py b/scripts/test-nextjs-ct-containers.py index 7f3fef88..ae7ef004 100644 --- a/scripts/test-nextjs-ct-containers.py +++ b/scripts/test-nextjs-ct-containers.py @@ -997,6 +997,26 @@ def test_an_enrolled_account_with_a_fault_keeps_its_drop_in(self): self.assertIn("unexpected claw-master agent env output", r.stderr) self.assertEqual(w.dropin.read_text(), want) + def test_first_install_faults_fence_direct_routing(self): + """A fresh enrolled account must not spend its own login while the client or unit cannot start.""" + for failure in ("missing_binary", "bad_env", "enable_failure", "start_failure"): + with self.subTest(failure=failure): + w = self.world(env_output='echo "HTTPS_PROXY=http://elsewhere:1"\n' if failure == "bad_env" else None) + if failure == "missing_binary": + Path(w.env["CM_AGENT_BIN"]).unlink() + if failure in ("enable_failure", "start_failure"): + ctl = w.d / "bin/systemctl" + ctl.write_text(ctl.read_text().replace('case "$1" in', + 'case "$1" in\n %s) exit 1 ;;' % failure.removesuffix("_failure"))) + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0, r.stderr) + self.assertEqual(json.loads(w.dropin.read_text()), {"env": { + "HTTPS_PROXY": "http://127.0.0.1:1", + "NODE_EXTRA_CA_CERTS": str(w.home / ".config/claude-master/ca.pem"), + "NO_PROXY": "localhost,127.0.0.1,::1"}}) + self.assertEqual(w.dropin.stat().st_mode & 0o777, 0o640) + self.assertEqual(list(w.dropin.parent.glob(".*")), [], "no temporary file is left behind") + def test_ready_means_the_units_own_process_listens(self): for why, kw in (("nothing listens", {"listening": False}), ("another process listens", {"owner_is_unit": False})): w = self.world(**kw) From 21c6764974aca6cd839c26230be323e51c12f4e4 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 04:26:32 +0000 Subject: [PATCH 5/5] Retry unready agents and copy renewal tokens without root privileges --- scripts/nextjs-ct/ct/claw-master-agent-setup | 4 +-- scripts/ssm/claw-master-cert-install.sh | 7 ++++- scripts/test-claw-master-cert-renew.py | 30 ++++++++++++++++++++ scripts/test-nextjs-ct-containers.py | 18 ++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/scripts/nextjs-ct/ct/claw-master-agent-setup b/scripts/nextjs-ct/ct/claw-master-agent-setup index b97e57b7..6ff072b9 100755 --- a/scripts/nextjs-ct/ct/claw-master-agent-setup +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -90,8 +90,6 @@ if ! systemctl is-active --quiet "$UNIT"; then elif [ -n "$RESTART" ] || [ "$(cat "$LOADED" 2>/dev/null)" != "$SUM" ]; then systemctl restart "$UNIT" || { say "$UNIT did not restart"; exit 1; } fi -echo "$SUM" > "$LOADED" || { say "cannot write $LOADED"; exit 1; } - # The drop-in is written whether or not the agent came up: the unit keeps restarting it, and failing beats spending the # person's own login. It carries the agent's token, so only the person (and root) can read it. install -d -m 755 "$(dirname "$DROPIN")" || { say "cannot make $(dirname "$DROPIN")"; exit 1; } @@ -112,10 +110,12 @@ PORT=${LISTEN##*:} for _ in $(seq 1 "$TRIES"); do PID=$(systemctl show -p MainPID --value "$UNIT") [ "${PID:-0}" != 0 ] && ss -Hltnp "sport = :$PORT" 2>/dev/null | grep -q "pid=$PID," && { + echo "$SUM" > "$LOADED" || { say "cannot write $LOADED"; exit 1; } echo "claw-master-agent-setup: $U uses the pool through $LISTEN" exit 0 } sleep 0.5 done +rm -f "$LOADED" say "$UNIT is not the listener on $LISTEN" exit 1 diff --git a/scripts/ssm/claw-master-cert-install.sh b/scripts/ssm/claw-master-cert-install.sh index fbeb4133..1f1fc957 100755 --- a/scripts/ssm/claw-master-cert-install.sh +++ b/scripts/ssm/claw-master-cert-install.sh @@ -28,7 +28,12 @@ if [ "$(id -u)" = 0 ]; then chown "$ACCOUNT": "$NEW/client.pem" "$NEW/ca.pem" || # The claw-master agent's token (nextjs-ct's claw-master-agent-setup) lives beside the certificate, where `claw-master connect` # looks for it. It belongs to the account, not to the certificate: without it connect refuses the settings that name it. if [ -f "$DIR/agent.token" ] && [ ! -L "$DIR/agent.token" ]; then - cp -p "$DIR/agent.token" "$NEW/agent.token" || { echo "INSTALL=token-copy-failed"; exit 1; } + # The account owns NEW and can replace its destination with a symlink. Never copy there with root's write access. + if [ "$(id -u)" = 0 ]; then + runuser -u "$ACCOUNT" -- cp -p "$DIR/agent.token" "$NEW/agent.token" + else + cp -p "$DIR/agent.token" "$NEW/agent.token" + fi || { echo "INSTALL=token-copy-failed"; exit 1; } fi rm -rf "$OLD" if [ -d "$DIR" ]; then mv "$DIR" "$OLD" || { echo "INSTALL=swap-failed"; exit 1; }; fi diff --git a/scripts/test-claw-master-cert-renew.py b/scripts/test-claw-master-cert-renew.py index 67ab5307..22e61025 100644 --- a/scripts/test-claw-master-cert-renew.py +++ b/scripts/test-claw-master-cert-renew.py @@ -274,6 +274,36 @@ def test_install_carries_the_agent_token_over(self): self.assertEqual(kv(self.install(home2, cert2).stdout).get("INSTALL"), "ok") self.assertFalse((home2 / ".config/claude-master/agent.token").exists()) + @unittest.skipIf(os.geteuid() == 0, "fixture copies as the current unprivileged account") + def test_root_install_copies_the_token_as_the_account_and_cannot_write_a_protected_target(self): + # Select the root branch with a fake id; runuser records the requested account and then runs as this test's + # unprivileged user. A separate privileged smoke check can exercise the same script with real root/runuser. + account = sh(["id", "-un"]).stdout.strip() + bin_ = Path(tempfile.mkdtemp(dir=self.tmp)) + trace = bin_ / "runuser-account" + (bin_ / "id").write_text('#!/bin/bash\n[ "$*" = -u ] && { echo 0; exit; }; exec /usr/bin/id "$@"\n') + (bin_ / "runuser").write_text('#!/bin/bash\n[ "$1" = -u ] && [ "$3" = -- ] || exit 9\n' + 'printf "%%s\\n" "$2" > "%s"\nshift 3\nexec "$@"\n' % trace) + for tool in ("id", "runuser"): + (bin_ / tool).chmod(0o755) + for linked in (False, True): + home = self.home(days=3) + token = home / ".config/claude-master/agent.token" + token.write_text("TOKEN\n"); token.chmod(0o600) + new, cert = self.pending(home, days=30) + protected = home / "protected-target" + protected.write_text("KEEP\n"); protected.chmod(0o400) + if linked: + (new / "agent.token").symlink_to(protected) + trace.unlink(missing_ok=True) + r = run_script(SCRIPTS["install"], {"CM_HOME_OVERRIDE": str(home), "PATH": str(bin_) + ":" + os.environ["PATH"]}, + Account=account, Name="nextjs-colton", Cert=b64(cert), Ca=b64(self.pki.pem), MinDays=20) + self.assertEqual(trace.read_text().strip(), account, "root must delegate the token copy") + self.assertEqual(protected.read_text(), "KEEP\n") + self.assertEqual(kv(r.stdout).get("INSTALL"), "token-copy-failed" if linked else "ok", r.stdout + r.stderr) + if linked: + self.assertNotEqual(r.returncode, 0) + def test_install_works_when_there_is_no_live_directory_yet(self): home = self.home(days=None) _, cert = self.pending(home) diff --git a/scripts/test-nextjs-ct-containers.py b/scripts/test-nextjs-ct-containers.py index ae7ef004..a6cbe5d2 100644 --- a/scripts/test-nextjs-ct-containers.py +++ b/scripts/test-nextjs-ct-containers.py @@ -1025,6 +1025,24 @@ def test_ready_means_the_units_own_process_listens(self): self.assertIn("is not the listener", r.stderr, why) self.assertEqual(json.loads(w.dropin.read_text()), self.ours(w), why + ": the drop-in still keeps Claude off the person's login") + def test_an_unready_start_clears_the_loaded_mark_and_retries_with_a_restart(self): + w = self.world(listening=False) + loaded = Path(w.env["CM_AGENT_LOADED"]) + loaded.write_text("a previous certificate\n") + r = self.run_setup(w) + self.assertNotEqual(r.returncode, 0, r.stderr) + self.assertFalse(loaded.exists(), "an active but unready unit must not keep a loaded-certificate mark") + srv = socket.socket() + self.addCleanup(srv.close) + srv.bind(("127.0.0.1", w.port)) + srv.listen(16) + r = self.run_setup(w) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(len(self.calls(w, "restart")), 1, "the active unready start must be retried") + self.assertTrue(loaded.is_file()) + self.assertEqual(self.run_setup(w).returncode, 0) + self.assertEqual(len(self.calls(w, "restart")), 1, "a ready certificate needs no further restart") + def test_the_units_run_the_agent_and_set_it_up_again(self): agent = (self.UNITS / "claw-master-agent@.service").read_text() for line in ("User=%i", "EnvironmentFile=/etc/nextjs-ct/claw-master-agent.env", "Restart=always",