diff --git a/AGENTS.md b/AGENTS.md index 633882e0..df031cc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -903,6 +903,20 @@ 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 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 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. 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 new file mode 100755 index 00000000..6ff072b9 --- /dev/null +++ b/scripts/nextjs-ct/ct/claw-master-agent-setup @@ -0,0 +1,121 @@ +#!/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 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: 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 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="" +[ "${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} +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 +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; } + +# 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; } + +# 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 + as_user test -r "$PEM" && return 0 + sleep 0.5 + done + as_user test -r "$PEM" +} + +if ! enrolled; then + systemctl disable --now "$UNIT" >/dev/null 2>&1 || true + 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. +# 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; } +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; } +# 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 +# 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 + +# 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 + 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/nextjs-ct/ct/provision b/scripts/nextjs-ct/ct/provision index b9db3291..c2c97be8 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,13 @@ 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 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 new file mode 100644 index 00000000..ed8300d9 --- /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 (restarted when its client certificate is not the one it runs with) + +[Service] +Type=oneshot +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 new file mode 100644 index 00000000..00019fad --- /dev/null +++ b/scripts/nextjs-ct/ct/units/claw-master-agent@.path @@ -0,0 +1,12 @@ +[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. 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] +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/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/ssm/claw-master-cert-install.sh b/scripts/ssm/claw-master-cert-install.sh index d7a159bb..1f1fc957 100755 --- a/scripts/ssm/claw-master-cert-install.sh +++ b/scripts/ssm/claw-master-cert-install.sh @@ -25,6 +25,16 @@ 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 + # 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 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..22e61025 100644 --- a/scripts/test-claw-master-cert-renew.py +++ b/scripts/test-claw-master-cert-renew.py @@ -254,6 +254,56 @@ 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()) + + @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 b7d46de4..cbf879c3 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 @@ -823,6 +824,254 @@ 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 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, owner_is_unit=True, env_output=None): + d = Path(tempfile.mkdtemp()) + self.addCleanup(subprocess.run, ["rm", "-rf", str(d)]) + 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", 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"}})) + 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 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"), + "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") + + def run_setup(self, w, *extra): + 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 {"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, 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_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.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_a_second_run_changes_nothing_and_restarts_nothing(self): + w = self.world() + self.assertEqual(self.run_setup(w).returncode, 0) + 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(self.calls(w, "restart"), []) + self.assertEqual(len(self.calls(w, "start")), 1) + + def test_a_different_certificate_restarts_the_agent_and_so_does_a_new_build(self): + w = self.world() + stamp = (time.time() - 3600, time.time() - 3600) + os.utime(w.pem, stamp) + self.assertEqual(self.run_setup(w).returncode, 0) + # 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) + 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_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() + 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_an_account_that_is_not_enrolled_has_no_agent_and_no_drop_in(self): + w = self.world() + 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_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() + 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("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("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) + 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_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", + "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\n", oneshot) + setup = self.SETUP.read_text() + 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('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"')) + + class AgentUpdateTests(unittest.TestCase): """The nightly agent-update brings every account to the latest Claude Code. Claude's own updater never runs in pool sessions (`claw-master connect` sets DISABLE_AUTOUPDATER=1), so on 2026-10-26 the job logged "2.1.294 -> 2.1.294" in every