Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions scripts/nextjs-ct/ct/claw-master-agent-setup
Original file line number Diff line number Diff line change
@@ -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; }
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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("^(?<key>[A-Z_]+)=(?<value>.+)$")] | 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
11 changes: 10 additions & 1 deletion scripts/nextjs-ct/ct/provision
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
6 changes: 6 additions & 0 deletions scripts/nextjs-ct/ct/units/claw-master-agent-setup@.service
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions scripts/nextjs-ct/ct/units/claw-master-agent@.path
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions scripts/nextjs-ct/ct/units/claw-master-agent@.service
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions scripts/nextjs-ct/ct/units/claw-master-agent@.timer
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions scripts/ssm/claw-master-cert-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions scripts/test-claw-master-cert-renew.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading