From 09049ba8249824b97702d0668a8247d353cc6d07 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 04:00:21 +0000 Subject: [PATCH 1/2] claude-update.tf: a daily `claude update` as ubuntu on the jumpboxes and metal boxes Nothing updated the native Claude Code on these boxes: the boot scripts run the installer only when it is missing, and Claude's own updater does not run in pool sessions, because `claw-master connect` sets DISABLE_AUTOUPDATER=1 for the Claude it launches. fcvm-metal-arm was on 2.1.296 while 2.1.311 was out (2026-10-27). - claude-update.tf: an SSM association (AWS-RunShellScript, the pattern of codex-update.tf) at 08:00 UTC daily on both jumpboxes, fcvm-metal-arm and fcvm-metal-x86. It runs `claude update` as ubuntu (runuser), from its home, stdin closed, DISABLE_AUTOUPDATER removed, and prints before -> after. A box without a native copy is reported and left alone; a failed update fails the run. It restarts nothing. - scripts/test-claude-update.py: renders the commands from the Terraform and runs them against a fake runuser and claude (update, failure, no copy), and pins the targets, the schedule and that nothing is restarted or downloaded. Added to lambda-tests.yml. - README.md and AGENTS.md: one line each, beside the Codex refresh. --- .github/workflows/lambda-tests.yml | 2 + AGENTS.md | 4 ++ README.md | 1 + claude-update.tf | 58 ++++++++++++++++++++ scripts/test-claude-update.py | 88 ++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 claude-update.tf create mode 100755 scripts/test-claude-update.py diff --git a/.github/workflows/lambda-tests.yml b/.github/workflows/lambda-tests.yml index f2f2e0be..c9d58753 100644 --- a/.github/workflows/lambda-tests.yml +++ b/.github/workflows/lambda-tests.yml @@ -237,6 +237,8 @@ jobs: run: python3 -S -B scripts/test-codex-restart.py - name: Codex refresh on the ubuntu boxes (weekly SSM association) run: python3 -S -B scripts/test-codex-update.py + - name: Daily Claude Code update on the ubuntu boxes (offline) + run: python3 -S -B scripts/test-claude-update.py - name: Shared claude-master server (private, pinned, idle until its logins exist) run: python3 -S -B scripts/test-claw-master-server.py - name: New repositories get Claude and Codex sessions within seconds (agent-session-sync) diff --git a/AGENTS.md b/AGENTS.md index 633882e0..03280be5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1124,6 +1124,10 @@ restart interrupts the turns it is running. On 2026-09-30 fcvm-arm's daemon (sta - **Refresh is automatic.** `codex-update.tf`: a weekly SSM association re-runs the installer as `ubuntu` on both jumpboxes and both metal boxes (a stopped spot box catches up at its next run). nextjs-dev refreshes every account daily in its own updater and restarts them there. +- **Claude Code updates daily too.** `claude-update.tf`: an SSM association at 08:00 UTC runs `claude update` as ubuntu on + both jumpboxes, fcvm-metal-arm and fcvm-metal-x86 (the nextjs-ct containers do it in their own nightly `agent-update`). + Claude's own updater never runs in pool sessions, because `claw-master connect` sets `DISABLE_AUTOUPDATER=1`. Nothing is + restarted: the next session starts the new binary. - **Restart is a decision.** `codex-restart` (installed on every box; `scripts/codex-restart.sh`) prints, per account, the version its daemon RUNS vs the one INSTALLED, the connected app clients and the commands running under it. `codex-restart --restart` restarts only the stale daemons, diff --git a/README.md b/README.md index bb3f71df..c5a10152 100644 --- a/README.md +++ b/README.md @@ -2979,6 +2979,7 @@ Remove `~/src/aws-browser-manager.before-public` once the Mac URL works. | Each member's own wbox, pbox and gbox (fixed private IPs, per-person keys, passwords and instance roles; `wbox` / `pbox` / `gbox` act on the member's own box when run from their account) | `member-boxes.tf`, `member-wbox.tf`, `member-pbox.tf`, `member-gbox.tf`, `scripts/wbox-member.sh` | | Shared claw-master server (t4g.micro at 10.0.1.50 holding the Claude subscription logins; boxes authenticate with certificates, Macs and a Windows PC through a Cloudflare tunnel, each with an Access service token; no password, nothing inbound) | `claw-master-server.tf`, `claw-master-tunnel.tf`, `scripts/claw-master-login.sh --server`, `scripts/claw-master-enroll.sh`, `scripts/claw-master-mac-bundle.sh`, `scripts/claw-master-windows-setup.sh` | | Codex refresh (weekly SSM association on the jumpboxes and metal boxes) and `codex-restart` (see running vs installed, restart deliberately) | `codex-update.tf`, `scripts/codex-restart.sh` | +| Claude Code update (daily SSM association running `claude update` as ubuntu on the jumpboxes and metal boxes) | `claude-update.tf` | | Starter user-level agent instructions (`~/.codex/AGENTS.md` with `~/.claude/CLAUDE.md` linked to it), created only for an account that has neither | `user-agents.tf`, `scripts/user-agents.md` (the text), `scripts/user-agents-seed.sh` | | Games multiplayer (ECS match engines, `play.cc-games.org`, `play.cc-games.net` and `play.cc-games.app`) | `games-multiplayer.tf`, `games-multiplayer-bringup.tf`, `games-multiplayer-deploy.tf` (automatic deploys: poller, builds, releases, migrations), `games-multiplayer-edge.tf` (WAF, access logs, router autoscaling, health alarms), `games-multiplayer/` (launch, poller, release and sweeper functions, bring-up steps, buildspecs), `docs/games-multiplayer.md` | | Imagine (`ejc3/imagine`: a collaborative editor's backend on ECS, zero tasks while unused, behind a host rule on the games load balancer at `imagine.play.cc-games.app`) | `imagine.tf` (bring-up order in its header), `imagine/scale.py` (the function that wakes, sleeps and rolls the service) | diff --git a/claude-update.tf b/claude-update.tf new file mode 100644 index 00000000..bb5134a3 --- /dev/null +++ b/claude-update.tf @@ -0,0 +1,58 @@ +# claude-update.tf +# +# Keeps the native Claude Code (~/.local/bin/claude) current, every day, on the four boxes that run it as `ubuntu`: both +# jumpboxes, fcvm-metal-arm and fcvm-metal-x86. +# +# WHY. Nothing updated it. The boot scripts run the installer only when Claude is MISSING, and Claude's own background +# updater does not run in pool sessions: `claw-master connect`, which t-claude launches every pool session through, sets +# DISABLE_AUTOUPDATER=1 for the Claude it starts. On 2026-10-27 fcvm-metal-arm was on 2.1.296 while 2.1.311 was out. The +# nextjs-ct containers update every account in their own nightly agent-update (scripts/nextjs-ct/ct/agent-update) and are +# not touched here. +# +# HOW. The pattern of codex-update.tf: an SSM State Manager association (Run Command on a schedule, no key on disk, no +# service on the box, the permitted admin -> box direction), daily at 08:00 UTC. `claude update` runs as ubuntu, never as +# root, from ubuntu's home with stdin closed and DISABLE_AUTOUPDATER removed, and writes only under ~/.local. A box with +# no native copy is reported and left alone (installing is the boot script's job). +# +# WHAT IT DELIBERATELY DOES NOT DO: restart anything. A running session keeps the binary it started with; the next session +# (and each t-claude relaunch) starts the new one. A box that is stopped at 08:00 catches up the next day. + +locals { + claude_update_instances = concat( + var.enable_jumpbox ? [aws_instance.jumpbox[0].id] : [], + var.enable_jumpbox_2 ? [aws_instance.jumpbox_2[0].id] : [], + var.enable_firecracker_instance ? [aws_instance.firecracker_dev[0].id] : [], + var.enable_x86_dev_instance ? [aws_instance.x86_dev[0].id] : [], + ) + + claude_update_commands = <<-CMD + set -u + before=$(runuser -u ubuntu -- /home/ubuntu/.local/bin/claude --version 2>/dev/null | head -1) + if [ -z "$before" ]; then echo "claude: no native install at /home/ubuntu/.local/bin/claude; nothing to update"; exit 0; fi + runuser -u ubuntu -- env -u DISABLE_AUTOUPDATER HOME=/home/ubuntu PATH=/home/ubuntu/.local/bin:/usr/local/bin:/usr/bin:/bin \ + sh -c 'cd /home/ubuntu && /home/ubuntu/.local/bin/claude update < /dev/null' >/dev/null 2>&1 \ + || { echo "claude update FAILED (was $before)"; exit 1; } + after=$(runuser -u ubuntu -- /home/ubuntu/.local/bin/claude --version 2>/dev/null | head -1) + echo "claude: $before -> $${after:-none}. Running sessions keep their binary until they restart." + CMD +} + +resource "aws_ssm_association" "claude_update" { + count = length(local.claude_update_instances) > 0 ? 1 : 0 + + name = "AWS-RunShellScript" + association_name = "claude-update" + schedule_expression = "cron(0 8 * * ? *)" + max_concurrency = "2" + max_errors = "1" + + targets { + key = "InstanceIds" + values = local.claude_update_instances + } + + parameters = { + commands = local.claude_update_commands + executionTimeout = "600" + } +} diff --git a/scripts/test-claude-update.py b/scripts/test-claude-update.py new file mode 100755 index 00000000..5de053c3 --- /dev/null +++ b/scripts/test-claude-update.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""claude-update.tf: the daily Claude Code update on the ubuntu boxes. Offline. + +The association's commands are rendered from the Terraform and run for real against a fake runuser and claude, with +ubuntu's home moved to a temp dir. Pins what makes it safe: it updates as ubuntu (never as root), without the disabled +updater, never restarts anything, and targets exactly the four ubuntu boxes (not nextjs-dev, whose containers update +themselves).""" +import os +import re +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +TF = (ROOT / "claude-update.tf").read_text() +CODE = re.sub(r"(?m)^\s*#.*$", "", TF) # comments may say "root" and "restart"; the code must not do them +COMMANDS = textwrap.dedent(TF.split("claude_update_commands = <<-CMD\n", 1)[1].split("\n CMD\n", 1)[0]).replace("$${", "${") + + +def fake(path, body): + path.write_text("#!/bin/bash\n" + body) + path.chmod(0o755) + + +class RunTests(unittest.TestCase): + def run_commands(self, claude="2.1.296", update_ok=True): + d = Path(tempfile.mkdtemp()) + self.addCleanup(subprocess.run, ["rm", "-rf", str(d)]) + home, bin_ = d / "ubuntu", d / "bin" + (home / ".local/bin").mkdir(parents=True) + bin_.mkdir() + # runuser -u ubuntu -- CMD...: record who it ran as, then run CMD. + fake(bin_ / "runuser", f'echo "runuser $2" >> {d}/log\nshift 3\nexec "$@"\n') + if claude: + (d / "version").write_text(claude) + fake(home / ".local/bin/claude", f"""case "$1" in + --version) echo "$(cat {d}/version) (Claude Code)" ;; + update) echo "update DISABLE_AUTOUPDATER=${{DISABLE_AUTOUPDATER:-unset}} cwd=$PWD HOME=$HOME" >> {d}/log + {'echo 2.1.311 > ' + str(d) + '/version' if update_ok else 'exit 1'} ;; +esac +""") + script = COMMANDS.replace("/home/ubuntu", str(home)) + env = {"PATH": f"{bin_}:/usr/bin:/bin", "DISABLE_AUTOUPDATER": "1"} + r = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=60) + log = (d / "log").read_text() if (d / "log").exists() else "" + return r, log, home + + def test_it_updates_to_the_latest_as_ubuntu_without_the_disabled_updater(self): + r, log, home = self.run_commands() + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("claude: 2.1.296 (Claude Code) -> 2.1.311 (Claude Code).", r.stdout) + self.assertIn("update DISABLE_AUTOUPDATER=unset cwd=%s HOME=%s" % (home, home), log) + self.assertEqual(set(re.findall(r"runuser (\S+)", log)), {"ubuntu"}, "every step runs as ubuntu") + + def test_a_failed_update_fails_the_run(self): + r, _, _ = self.run_commands(update_ok=False) + self.assertNotEqual(r.returncode, 0) + self.assertIn("claude update FAILED (was 2.1.296 (Claude Code))", r.stdout) + + def test_a_box_without_claude_is_reported_and_left_alone(self): + r, log, _ = self.run_commands(claude=None) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertIn("no native install", r.stdout) + self.assertNotIn("update", log) + + +class TerraformTests(unittest.TestCase): + def test_it_never_restarts_installs_or_runs_a_download(self): + for word in ("systemctl", "kill", "reboot", "curl", "install.sh", "sudo"): + self.assertNotIn(word, CODE, word) + + def test_it_targets_the_four_ubuntu_boxes_only(self): + block = TF.split("claude_update_instances = concat(", 1)[1].split("\n )\n", 1)[0] + self.assertEqual(re.findall(r"aws_instance\.(\w+)\[0\]\.id", block), ["jumpbox", "jumpbox_2", "firecracker_dev", "x86_dev"]) + self.assertNotIn("nextjs", CODE) + + def test_it_is_a_daily_association(self): + assoc = TF.split('resource "aws_ssm_association" "claude_update"', 1)[1] + self.assertIn('name = "AWS-RunShellScript"', assoc) + self.assertIn('schedule_expression = "cron(0 8 * * ? *)"', assoc) + self.assertIn('key = "InstanceIds"', assoc) + self.assertIn("commands = local.claude_update_commands", assoc) + + +if __name__ == "__main__": + unittest.main(verbosity=2, buffer=True) From 40f6e5e2cd0dd1aa5e808832e7ef9d40c5e82902 Mon Sep 17 00:00:00 2001 From: ejc3 Date: Tue, 27 Oct 2026 04:03:38 +0000 Subject: [PATCH 2/2] claude-update: the test checks the update call itself runs as ubuntu; the container note does not overclaim - test-claude-update.py: the fake runuser exports who it ran as and the fake claude logs it with the update, so dropping the update's runuser wrapper fails the test (it passed before: the version probes alone logged ubuntu). Seen failing with the wrapper removed. - claude-update.tf and AGENTS.md: the nextjs-ct containers are outside this association and covered by their own agent-update, without claiming what that job does. --- AGENTS.md | 2 +- claude-update.tf | 4 ++-- scripts/test-claude-update.py | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 03280be5..8ecd4c76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1125,7 +1125,7 @@ restart interrupts the turns it is running. On 2026-09-30 fcvm-arm's daemon (sta `ubuntu` on both jumpboxes and both metal boxes (a stopped spot box catches up at its next run). nextjs-dev refreshes every account daily in its own updater and restarts them there. - **Claude Code updates daily too.** `claude-update.tf`: an SSM association at 08:00 UTC runs `claude update` as ubuntu on - both jumpboxes, fcvm-metal-arm and fcvm-metal-x86 (the nextjs-ct containers do it in their own nightly `agent-update`). + both jumpboxes, fcvm-metal-arm and fcvm-metal-x86 (the nextjs-ct containers are outside it: their own nightly `agent-update` covers them). Claude's own updater never runs in pool sessions, because `claw-master connect` sets `DISABLE_AUTOUPDATER=1`. Nothing is restarted: the next session starts the new binary. - **Restart is a decision.** `codex-restart` (installed on every box; `scripts/codex-restart.sh`) diff --git a/claude-update.tf b/claude-update.tf index bb5134a3..15e236c8 100644 --- a/claude-update.tf +++ b/claude-update.tf @@ -6,8 +6,8 @@ # WHY. Nothing updated it. The boot scripts run the installer only when Claude is MISSING, and Claude's own background # updater does not run in pool sessions: `claw-master connect`, which t-claude launches every pool session through, sets # DISABLE_AUTOUPDATER=1 for the Claude it starts. On 2026-10-27 fcvm-metal-arm was on 2.1.296 while 2.1.311 was out. The -# nextjs-ct containers update every account in their own nightly agent-update (scripts/nextjs-ct/ct/agent-update) and are -# not touched here. +# nextjs-ct containers are outside this association: their own nightly agent-update (scripts/nextjs-ct/ct/agent-update) +# covers their accounts. # # HOW. The pattern of codex-update.tf: an SSM State Manager association (Run Command on a schedule, no key on disk, no # service on the box, the permitted admin -> box direction), daily at 08:00 UTC. `claude update` runs as ubuntu, never as diff --git a/scripts/test-claude-update.py b/scripts/test-claude-update.py index 5de053c3..1b57f08d 100755 --- a/scripts/test-claude-update.py +++ b/scripts/test-claude-update.py @@ -32,12 +32,12 @@ def run_commands(self, claude="2.1.296", update_ok=True): (home / ".local/bin").mkdir(parents=True) bin_.mkdir() # runuser -u ubuntu -- CMD...: record who it ran as, then run CMD. - fake(bin_ / "runuser", f'echo "runuser $2" >> {d}/log\nshift 3\nexec "$@"\n') + fake(bin_ / "runuser", f'echo "runuser $2" >> {d}/log\nexport RUNUSER_AS=$2\nshift 3\nexec "$@"\n') if claude: (d / "version").write_text(claude) fake(home / ".local/bin/claude", f"""case "$1" in --version) echo "$(cat {d}/version) (Claude Code)" ;; - update) echo "update DISABLE_AUTOUPDATER=${{DISABLE_AUTOUPDATER:-unset}} cwd=$PWD HOME=$HOME" >> {d}/log + update) echo "update as=${{RUNUSER_AS:-root}} DISABLE_AUTOUPDATER=${{DISABLE_AUTOUPDATER:-unset}} cwd=$PWD HOME=$HOME" >> {d}/log {'echo 2.1.311 > ' + str(d) + '/version' if update_ok else 'exit 1'} ;; esac """) @@ -51,7 +51,8 @@ def test_it_updates_to_the_latest_as_ubuntu_without_the_disabled_updater(self): r, log, home = self.run_commands() self.assertEqual(r.returncode, 0, r.stderr) self.assertIn("claude: 2.1.296 (Claude Code) -> 2.1.311 (Claude Code).", r.stdout) - self.assertIn("update DISABLE_AUTOUPDATER=unset cwd=%s HOME=%s" % (home, home), log) + self.assertIn("update as=ubuntu DISABLE_AUTOUPDATER=unset cwd=%s HOME=%s" % (home, home), log, + "the update itself runs through runuser -u ubuntu") self.assertEqual(set(re.findall(r"runuser (\S+)", log)), {"ubuntu"}, "every step runs as ubuntu") def test_a_failed_update_fails_the_run(self):