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
2 changes: 2 additions & 0 deletions .github/workflows/lambda-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
58 changes: 58 additions & 0 deletions claude-update.tf
Original file line number Diff line number Diff line change
@@ -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"
}
}
89 changes: 89 additions & 0 deletions scripts/test-claude-update.py
Original file line number Diff line number Diff line change
@@ -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)
Loading