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..8ecd4c76 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 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`) 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..15e236c8 --- /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 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 +# 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..1b57f08d --- /dev/null +++ b/scripts/test-claude-update.py @@ -0,0 +1,89 @@ +#!/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\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 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 +""") + 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 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): + 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)