diff --git a/AGENTS.md b/AGENTS.md index d5a7c65..3d56da7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1085,12 +1085,18 @@ folder every 5 minutes (`agents-enable`) and the jumpboxes looked nowhere. Measu launcher: a live `--remote-control` session existed 0.9 s after a clone finished; worst case is the 5 s scan plus about a second. -- **What is new.** A folder marked for interactive use after the watcher first looked: `claude-interactive` - in its git directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`). A checkout directly under - `~/*` or `~/src/*`, or a linked worktree of one wherever it lives; a subagent worktree under - `.claude/worktrees/` never counts. An unmarked clone gets no session (owner, 2026-10-13: sessions for - what is used interactively). The checkout must be finished (no `*.lock`, the index exists). The first run - only records what exists and launches nothing. +- **What is new.** A repository that appears after the watcher first looked. On dev boxes, every checkout + directly under `~/*` or `~/src/*` counts on its own: a clone or a new repository gets a session (owner, 2026-10-27: + "clone is fine, I just don't want one for every internal worktree that agents create"). A linked worktree + of one, wherever it lives, counts only when marked for interactive use: `claude-interactive` in its git + directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`); a subagent worktree under + `.claude/worktrees/` never counts. The checkout must be finished (no `*.lock`, the index exists). The + first run only records what exists and launches nothing, and so does the first scan of a state file from + before every clone counted (no `clones` key), so the upgrade does not open a session in every old clone. +- **The admin jumpboxes require explicit approval for new clones.** Both jumpboxes set `--require-clone-marker` + through their root-owned `ASS_ARGS` policy, including cold bootstrap. Their main checkouts require the same + `claude-interactive` marker as worktrees before either Claude or Codex starts. An allowed origin, including + the collaboration repository, does not authorize its startup hooks to run with administrator credentials. - **Whose repos.** The account's own GitHub login (from gh's `hosts.yml`) plus `~/.config/agent-session-sync/owners` and `--owner`. The ubuntu account on the metal boxes and jumpboxes also gets `dolphin-labs-hq/dolphin-labs` by exact name; never an organisation wildcard. diff --git a/agent-session-sync.tf b/agent-session-sync.tf index 69831c4..341df2c 100644 --- a/agent-session-sync.tf +++ b/agent-session-sync.tf @@ -5,8 +5,9 @@ # jumpboxes. Before this, the metal boxes looked once at boot (fcvm-claude-rc), nextjs-dev started only # each user's one working folder every 5 minutes (agents-enable), and the jumpboxes looked nowhere, so # a repo cloned today waited for a reboot or a person. See scripts/agent-session-sync.py for what -# counts as new: a checkout or worktree MARKED for interactive use (claude-interactive in its git dir), a -# finished clone, an allowed owner, and nothing that existed when the watcher first looked. After a reboot +# counts as new: any checkout on dev boxes, or a checkout MARKED for interactive use on admin jumpboxes; +# worktrees always require that marker (claude-interactive in the git dir). A finished clone, an allowed +# owner, and nothing that existed when the watcher first looked. After a reboot # it opens the folders scripts/agent-active-dirs.py names (prompted or marked in the last 30 days). # # ONE script and ONE template unit, installed by the same snippet everywhere, so the boxes cannot @@ -37,18 +38,46 @@ INSTALL # ejc3's own repositories (its gh login is added automatically) and ONE collaboration repository by # exact name, never an organisation wildcard (the same gate fcvm-claude-rc uses at boot). agent_session_sync_ubuntu_args = "--owner ejc3 --repo dolphin-labs-hq/dolphin-labs" + # An allowed origin does not approve its hooks running with jumpbox administrator credentials. + agent_session_sync_admin_args = "${local.agent_session_sync_ubuntu_args} --require-clone-marker" + + # Fence an old watcher before installing code whose dev-box default admits unmarked clones. Stopping + # this unit leaves interactive agents and tmux alive (KillMode=process). A failed stop must stop setup. + agent_session_sync_prepare_admin = <<-PREPARE +policy=/etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf +install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d || exit 1 +printf '[Service]\nEnvironment="ASS_ARGS=%s"\n' '${local.agent_session_sync_admin_args}' > "$policy.new" || exit 1 +chmod 644 "$policy.new" || exit 1 +if ! cmp -s "$policy.new" "$policy"; then + if [ "$(systemctl show agent-session-sync@ubuntu.service -p LoadState --value 2>/dev/null)" != not-found ]; then + systemctl stop agent-session-sync@ubuntu.service || { rm -f "$policy.new"; exit 1; } + fi + mv -f "$policy.new" "$policy" || exit 1 + systemctl daemon-reload || exit 1 +else + rm -f "$policy.new" +fi +PREPARE agent_session_sync_enable_ubuntu = <<-ENABLE -install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d -cat > /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf <<'POLICY' +install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d || exit 1 +cat > /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new <<'POLICY' || exit 1 [Service] Environment="ASS_ARGS=${local.agent_session_sync_ubuntu_args}" POLICY +chmod 644 /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new \ + && mv -f /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf.new /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf || exit 1 systemctl daemon-reload systemctl enable agent-session-sync@ubuntu.service >/dev/null 2>&1 || true # Starting a stopped watcher is safe; a running one is left alone so a re-run never resets it. systemctl is-active --quiet agent-session-sync@ubuntu.service || systemctl start agent-session-sync@ubuntu.service || true ENABLE + + agent_session_sync_enable_admin = replace( + local.agent_session_sync_enable_ubuntu, + local.agent_session_sync_ubuntu_args, + local.agent_session_sync_admin_args, + ) } # The jumpboxes ignore user_data changes and run no downloaded script as root, so a running one gets @@ -64,13 +93,13 @@ resource "terraform_data" "admin_agent_session_sync" { active = filesha256("${path.module}/scripts/agent-active-dirs.py") unit = filesha256("${path.module}/scripts/agent-session-sync@.service") helper = filesha256("${path.module}/scripts/ssm-agent-session-sync.sh") - args = local.agent_session_sync_ubuntu_args + args = local.agent_session_sync_admin_args } provisioner "local-exec" { command = "bash ${path.module}/scripts/ssm-agent-session-sync.sh ${each.value} ${var.aws_region}" environment = { - ASS_ARGS = local.agent_session_sync_ubuntu_args + ASS_ARGS = local.agent_session_sync_admin_args } } } diff --git a/jumpbox2-user-data.tf b/jumpbox2-user-data.tf index 9cbbf58..2406d1a 100644 --- a/jumpbox2-user-data.tf +++ b/jumpbox2-user-data.tf @@ -185,12 +185,14 @@ FLEET_HOST_ROLE=admin ${local.codex_remote_control} # ---------------------------------------------------------------- new repos within seconds -# A cheap 5-second watch (agent-session-sync.tf): a repository cloned later gets a Claude session and a -# Codex thread without a person or a reboot. It runs as ubuntu, starts nothing when there is no tmux -# server, and downloads nothing: the script is this file's own copy. +# A cheap 5-second watch (agent-session-sync.tf): a repository marked for interactive use gets a Claude +# session and a Codex thread without a reboot. Both admin jumpboxes require the marker before starting +# either agent; an allowed repository origin does not approve its startup hooks. It runs as ubuntu, +# starts nothing when there is no tmux server, and downloads nothing: the script is this file's own copy. ${local.codex_seed_thread_install} +${local.agent_session_sync_prepare_admin} ${local.agent_session_sync_install} -${local.agent_session_sync_enable_ubuntu} +${local.agent_session_sync_enable_admin} # ---------------------------------------------------------------- user-level agent instructions # A starter ~/.codex/AGENTS.md with ~/.claude/CLAUDE.md linked to it, only when ubuntu has diff --git a/scripts/agent-session-sync.py b/scripts/agent-session-sync.py index 83b560c..5a63200 100644 --- a/scripts/agent-session-sync.py +++ b/scripts/agent-session-sync.py @@ -5,19 +5,24 @@ or agent-session-sync@.service on nextjs-dev) on every box that keeps remote-control sessions. It replaces "the boot launcher looked once, at boot" with a cheap watch loop (default every 5 s). -WHAT COUNTS AS NEW. A folder marked for interactive use after the watcher first looked: the file -`claude-interactive` in its git directory (`touch "$(git rev-parse --git-dir)/claude-interactive"`, which -agents do when they clone a repository or create a worktree the user wants to prompt). Owner's rule, -2026-10-13: a session for what is used interactively, not for every clone. Looked for in: - * a checkout directly under one of the roots (default ~/* and ~/src/*) whose `.git` is a directory; - * a linked worktree of such a checkout, wherever it lives (git records it in .git/worktrees/); - a subagent's worktree under .claude/worktrees/ never counts; +WHAT COUNTS AS NEW. A repository that appears after the watcher first looked: + * every checkout directly under one of the roots (default ~/* and ~/src/*) whose `.git` is a + directory: a clone or a new repository gets a session on its own (owner, 2026-10-27: "clone is fine, + I just don't want one for every internal worktree that agents create"); + * a linked worktree of such a checkout, wherever it lives (git records it in .git/worktrees/), + only when it is marked for interactive use: the file `claude-interactive` in its git directory + (`touch "$(git rev-parse --git-dir)/claude-interactive"`), which agents do for a worktree the user + wants to prompt; a subagent's worktree under .claude/worktrees/ never counts; * finished: HEAD resolves, no *.lock in .git (a clone, fetch or checkout is still running) and the index exists (a clone writes it last), so a half-cloned repo is not launched; * allowed: its origin is owned by an allowed owner (the account's own GitHub login, plus ~/.config/agent-session-sync/owners and --owner) or is an exact --repo, and it is not claude-code-sync. +On administration jumpboxes, --require-clone-marker also requires claude-interactive for a main +checkout before either agent starts. An allowed origin alone does not approve repository startup hooks. The FIRST run only records what already exists and launches nothing: the boot launcher decided about -those. +those. A state file written before every clone counted (no "clones" key) is adopted the same way: the +first scan with this version records the unmarked checkouts already there without launching them, so the +upgrade does not open a session in every old clone at once. AFTER A REBOOT it starts Claude in every folder agent-active-dirs names (a person prompted there, or it was marked, in the last 30 days): the same rule the boot launchers use, so a folder nobody prompted stays closed. @@ -157,8 +162,8 @@ def is_allowed(path, owners, repos): return owner.lower() in owners or "%s/%s" % (owner, repo) in repos -def candidates(roots): - """Folders marked for interactive use: checkouts directly under the roots, and their linked worktrees.""" +def candidates(roots, require_clone_marker=False): + """Main checkouts (marked too on admin boxes) and marked linked worktrees.""" seen = set() for pattern in roots: for p in glob.glob(pattern): @@ -169,7 +174,7 @@ def candidates(roots): if not is_main_checkout(real): continue git = os.path.join(real, ".git") - if real not in seen and os.path.exists(os.path.join(git, MARKER)): + if real not in seen and (not require_clone_marker or os.path.exists(os.path.join(git, MARKER))): seen.add(real) yield real for admin in sorted(glob.glob(os.path.join(git, "worktrees", "*"))): @@ -279,6 +284,9 @@ def __init__(self, path, boot_id=None): self.path = path self.first_run = not os.path.exists(path) self.known = {} + # True once the state was written by a version that launches every clone. An older file lacks it: + # its first scan here records the unmarked checkouts already present instead of launching them. + self.clones = self.first_run # After a reboot: the active folders still to launch, and those launched this boot (trust is kept for them). self.boot = {"pending": [], "launched": [], "tried": {}} self.boot_id = current_boot_id() if boot_id is None else boot_id @@ -290,6 +298,7 @@ def __init__(self, path, boot_id=None): self.known = data.get("known", {}) self.boot.update(data.get("boot") or {}) saved = data.get("boot_id") + self.clones = bool(data.get("clones")) except (OSError, ValueError): self.known = {} # An older state file has no boot id: not evidence of a reboot. @@ -299,7 +308,8 @@ def save(self): os.makedirs(os.path.dirname(self.path), exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(self.path), prefix=".known.") with os.fdopen(fd, "w") as fh: - json.dump({"known": self.known, "boot_id": self.boot_id, "boot": self.boot}, fh, indent=1, sort_keys=True) + json.dump({"known": self.known, "boot_id": self.boot_id, "boot": self.boot, "clones": self.clones}, + fh, indent=1, sort_keys=True) os.replace(tmp, self.path) @@ -308,11 +318,14 @@ def save(self): def scan_once(cfg, state, launcher, now=None): """One pass. Returns the repos a launch was attempted for.""" now = time.time() if now is None else now - found = list(candidates(cfg["roots"])) + found = list(candidates(cfg["roots"], cfg.get("require_clone_marker", False))) present = set(found) attempted = [] - # A path that is no longer a marked checkout is forgotten, so deleting and re-cloning is new again. + # Recording only, not launching: the first run, or the first scan after upgrading to "every clone counts". + adopting = state.first_run or not state.clones + + # A path that is no longer a candidate is forgotten, so deleting and re-cloning is new again. for path in [p for p in state.known if p not in present]: del state.known[path] @@ -363,12 +376,12 @@ def scan_once(cfg, state, launcher, now=None): log("new repository (its owner is allowed now): %s" % path) if entry is None: ident = repo_identity(path) - if not is_allowed(path, owners, repos): - state.known[path] = {"ignored": True, "seeded": state.first_run, "id": ident} - continue if not is_finished(path): continue # a clone still running: looked at again next tick - if state.first_run: + if not is_allowed(path, owners, repos): + state.known[path] = {"ignored": True, "seeded": adopting, "id": ident} + continue + if adopting: state.known[path] = {"claude": True, "codex": True, "seeded": True, "id": ident} continue entry = state.known[path] = {"claude": False, "codex": False, "tried": {}, "id": ident} @@ -394,7 +407,10 @@ def scan_once(cfg, state, launcher, now=None): # a failed attempt waits RETRY_SECONDS, except "no tmux server yet" (cheap to poll) if getattr(launcher, "cheap_retry", {}).get(kind): tried.pop(kind, None) + if not state.first_run and not state.clones: + log("adopted the checkouts already present; from now on every new clone gets a session") state.first_run = False + state.clones = True keep_trust(cfg["home"], [p for p, e in state.known.items() if e.get("claude") and not e.get("seeded")] + list(state.boot.get("launched", [])), state) state.save() @@ -562,6 +578,7 @@ def build_config(args): state_dir = args.state_dir or os.path.join(home, ".local", "state", "agent-session-sync") return { "home": home, "roots": roots, "state_dir": state_dir, + "require_clone_marker": args.require_clone_marker, "owners": allowed_owners(home, args.owner or []), "owners_extra": list(args.owner or []), "owners_sig": owners_signature(home), "repos": set(args.repo or []), "tmux": args.tmux, "t_claude_arg": args.t_claude, "codex_seed": args.codex_seed, @@ -596,6 +613,8 @@ def main(argv): ap.add_argument("--root", action="append", help="glob of checkout directories (default ~/* and ~/src/*)") ap.add_argument("--owner", action="append", help="allowed GitHub owner (also: the account's own gh login)") ap.add_argument("--repo", action="append", help="one allowed OWNER/REPO, exactly") + ap.add_argument("--require-clone-marker", action="store_true", + help="require claude-interactive for main checkouts too (administration jumpboxes)") ap.add_argument("--t-claude", default="auto", help="t-claude.zsh to source (default: the system copy, else ~/.config/t-claude.zsh)") ap.add_argument("--codex-seed", default="/usr/local/bin/codex-seed-thread") ap.add_argument("--active-dirs", default="/usr/local/bin/agent-active-dirs") diff --git a/scripts/ssm-agent-session-sync.sh b/scripts/ssm-agent-session-sync.sh index 4cd2285..84b27ae 100755 --- a/scripts/ssm-agent-session-sync.sh +++ b/scripts/ssm-agent-session-sync.sh @@ -43,12 +43,19 @@ echo $unit | base64 -d > "\$t/unit" printf '[Service]\nEnvironment="ASS_ARGS=%s"\n' '$ASS_ARGS' > "\$t/policy" restart=0 put() { cmp -s "\$1" "\$2" && return 1; install -m "\$3" "\$1" "\$2.new" && mv -f "\$2.new" "\$2"; } +# An old watcher self-execs with its old arguments when its script changes. Fence it and install the +# stricter admin policy BEFORE publishing new code; a failed stop aborts without publishing that code. +install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d +if ! cmp -s "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf; then + if [ "\$(systemctl show agent-session-sync@ubuntu.service -p LoadState --value 2>/dev/null)" != not-found ]; then + systemctl stop agent-session-sync@ubuntu.service + fi + put "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf 644 +fi put "\$t/script" /usr/local/bin/agent-session-sync 755 || true put "\$t/seeder" /usr/local/bin/codex-seed-thread 755 || true # the watcher seeds Codex threads through it put "\$t/active" /usr/local/bin/agent-active-dirs 755 || true # which folders open after a reboot put "\$t/unit" /etc/systemd/system/agent-session-sync@.service 644 && restart=1 -install -d /etc/systemd/system/agent-session-sync@ubuntu.service.d -put "\$t/policy" /etc/systemd/system/agent-session-sync@ubuntu.service.d/policy.conf 644 && restart=1 systemctl daemon-reload systemctl enable agent-session-sync@ubuntu.service >/dev/null 2>&1 if systemctl is-active --quiet agent-session-sync@ubuntu.service; then diff --git a/scripts/test-agent-session-sync.py b/scripts/test-agent-session-sync.py index 9a010fb..be03ee8 100644 --- a/scripts/test-agent-session-sync.py +++ b/scripts/test-agent-session-sync.py @@ -2,6 +2,8 @@ """scripts/agent-session-sync.py: a new repository is noticed fast, and only a real new one. Offline: real git repositories in a temp directory and a fake launcher.""" import importlib.util +import base64 +import json import os import shutil import subprocess @@ -123,16 +125,16 @@ def test_an_unmarked_worktree_is_not_a_candidate_and_a_marked_one_is(self): found = [os.path.basename(p) for p in ass.candidates(self.cfg["roots"])] self.assertIn("main-feature", found) - def test_an_unmarked_clone_is_not_launched_until_it_is_marked(self): + def test_an_unmarked_clone_is_launched(self): r = make_repo(self.home, "plain", marked=False) self.scan(now=1005) - self.assertEqual(self.launcher.calls, [], "only folders marked for interactive use get a session") - mark(r) - self.scan(now=1010) - self.assertEqual(sorted(self.launcher.calls), [("claude", str(r)), ("codex", str(r))]) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(r)), ("codex", str(r))], + "every clone gets a session; only worktrees need the marker") def test_a_marked_worktree_elsewhere_is_launched_and_takes_the_main_checkout_s_owner(self): - main = make_repo(self.home, "main", marked=False) # the checkout itself is not for interactive use + main = make_repo(self.home, "main", marked=False) + self.scan(now=1003) # the clone itself gets its session first + self.launcher.calls.clear() wt = Path(self.tmp) / "elsewhere" / "feature" git(main, "worktree", "add", "-q", str(wt), "-b", "feature") mark(wt) @@ -145,7 +147,9 @@ def test_a_subagent_worktree_never_counts_even_when_marked(self): git(main, "worktree", "add", "-q", str(wt), "-b", "agent-1") mark(wt) self.scan(now=1005) - self.assertEqual(self.launcher.calls, []) + self.assertNotIn(str(wt), [p for _, p in self.launcher.calls]) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(main)), ("codex", str(main))], + "only the clone itself gets a session") def test_nothing_nested_below_a_root_is_scanned(self): make_repo(self.home / "projects", "deep") @@ -161,6 +165,19 @@ def test_a_clone_still_running_is_not_launched(self): self.scan(now=1010) self.assertEqual(sorted(self.launcher.calls), [("claude", str(r)), ("codex", str(r))]) + def test_a_clone_seen_before_its_origin_is_written_is_rechecked_when_finished(self): + r = self.home / "cloning" + subprocess.run(["git", "init", "-q", str(r)], check=True, env=ENV) + self.scan(now=1005) + self.assertEqual(self.launcher.calls, []) + git(r, "remote", "add", "origin", "https://github.com/ejc3/example") + (r / "f").write_text("x") + git(r, "add", "f") + git(r, "commit", "-q", "-m", "clone finished") + self.scan(now=1010) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(r)), ("codex", str(r))], + "a clone without its origin yet must not be ignored permanently") + def test_a_repo_with_no_commit_or_no_checkout_yet_is_not_launched(self): make_repo(self.home, "empty", commit=False) self.scan(now=1005) @@ -180,6 +197,64 @@ def test_hidden_directories_are_ignored(self): self.assertEqual(self.launcher.calls, []) +class UpgradeToEveryCloneTests(Base): + """A state file from the version that needed a marker on every clone: upgrading must not open a session in + every old clone at once.""" + + def old_state(self, known): + os.makedirs(self.cfg["state_dir"], exist_ok=True) + path = os.path.join(self.cfg["state_dir"], "known.json") + with open(path, "w") as fh: + json.dump({"known": known, "boot_id": "boot-A", "boot": {}}, fh) + return ass.State(path, boot_id="boot-A") + + def test_the_first_scan_after_the_upgrade_adopts_old_unmarked_clones_without_launching(self): + old = make_repo(self.home, "old-clone", marked=False) + state = self.old_state({}) + self.assertFalse(state.clones) + self.scan(now=1005, state=state) + self.assertEqual(self.launcher.calls, [], "existing clones are recorded, not launched") + self.assertTrue(state.known[str(old)].get("seeded")) + self.assertTrue(ass.State(state.path, boot_id="boot-A").clones, "the adoption is saved") + + def test_a_clone_made_after_the_upgrade_is_launched(self): + state = self.old_state({}) + self.scan(now=1005, state=state) + new = make_repo(self.home, "new-clone", marked=False) + self.scan(now=1010, state=state) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(new)), ("codex", str(new))]) + + def test_a_fresh_state_records_the_new_policy_on_its_first_run(self): + self.scan(now=1000) + self.assertTrue(ass.State(self.state.path).clones) + + +class AdminClonePolicyTests(Base): + def test_an_admin_clone_waits_for_the_marker_before_both_agents_start(self): + self.cfg["require_clone_marker"] = True + self.scan() + repo = make_repo(self.home, "admin-clone", marked=False) + self.scan(now=1005) + self.assertEqual(self.launcher.calls, []) + mark(repo) + self.scan(now=1010) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(repo)), ("codex", str(repo))]) + + def test_the_admin_cli_requires_the_marker_for_the_allowed_collaboration_repo(self): + launcher = ass.Launcher + ass.Launcher = lambda cfg: self.launcher + self.addCleanup(setattr, ass, "Launcher", launcher) + args = ["--home", str(self.home), "--state-dir", self.cfg["state_dir"], "--owner", "ejc3", + "--repo", "dolphin-labs-hq/dolphin-labs", "--require-clone-marker", "--once"] + ass.main(args) + repo = make_repo(self.home, "collaboration", origin="https://github.com/dolphin-labs-hq/dolphin-labs", marked=False) + ass.main(args) + self.assertEqual(self.launcher.calls, [], "an allowed origin does not approve startup hooks") + mark(repo) + ass.main(args) + self.assertEqual(sorted(self.launcher.calls), [("claude", str(repo)), ("codex", str(repo))]) + + class OwnerTests(Base): def setUp(self): super().setUp() @@ -283,15 +358,32 @@ def test_the_accounts_that_get_one(self): self.assertIn('systemctl enable "agent-session-sync@$u.service"', tf) self.assertIn('local.nextjs_codex_seed_users', tf[tf.index('agent-session-sync@$u.service') - 400:tf.index('agent-session-sync@$u.service')]) self.assertIn("agent_session_sync_enable_ubuntu", self.read("claude-remote-control.tf")) - self.assertIn("agent_session_sync_enable_ubuntu", self.read("jumpbox2-user-data.tf")) + self.assertIn("agent_session_sync_enable_admin", self.read("jumpbox2-user-data.tf")) def test_a_running_jumpbox_converges_through_a_reviewed_step(self): tf = self.read("agent-session-sync.tf") block = tf[tf.index('resource "terraform_data" "admin_agent_session_sync"'):] for trigger in ("local.admin_tmux_boxes", "agent-session-sync.py", "agent-session-sync@.service", - "ssm-agent-session-sync.sh", "local.agent_session_sync_ubuntu_args"): + "ssm-agent-session-sync.sh", "local.agent_session_sync_admin_args"): self.assertIn(trigger, block) + def test_both_admin_entry_paths_require_clone_markers_and_dev_boxes_keep_the_default(self): + tf = self.read("agent-session-sync.tf") + self.assertIn('agent_session_sync_admin_args = "${local.agent_session_sync_ubuntu_args} --require-clone-marker"', tf) + admin = tf.split('resource "terraform_data" "admin_agent_session_sync"', 1)[1] + self.assertIn("for_each = local.admin_tmux_boxes", admin) + self.assertEqual(admin.count("local.agent_session_sync_admin_args"), 2, "trigger and SSM environment") + self.assertNotIn("local.agent_session_sync_ubuntu_args", admin) + enable = tf.split("agent_session_sync_enable_admin = replace(", 1)[1].split("\n )", 1)[0] + for value in ("local.agent_session_sync_enable_ubuntu", "local.agent_session_sync_ubuntu_args", "local.agent_session_sync_admin_args"): + self.assertIn(value, enable) + boot = self.read("jumpbox2-user-data.tf") + self.assertEqual(boot.count("content = local.jumpbox_2_user_data"), 2, "both admin S3 boot objects") + self.assertIn("${local.agent_session_sync_enable_admin}", boot) + self.assertNotIn("${local.agent_session_sync_enable_ubuntu}", boot) + self.assertLess(boot.index("${local.agent_session_sync_prepare_admin}"), boot.index("${local.agent_session_sync_install}")) + self.assertNotIn("agent_session_sync_enable_admin", self.read("claude-remote-control.tf")) + def test_the_unit_never_takes_a_session_with_it(self): unit = self.read("scripts/agent-session-sync@.service") self.assertIn("KillMode=process", unit) @@ -333,6 +425,8 @@ def test_a_restart_of_the_watcher_on_the_same_boot_relaunches_nothing(self): def test_after_a_reboot_claude_starts_in_the_active_folders_only_and_codex_is_left_alone(self): idle = make_repo(self.home, "idle", marked=False) + self.scan(now=1010, state=self.state) # a clone from before the reboot that nobody prompted + self.launcher.calls.clear() self.active = [str(self.fresh)] # agent-active-dirs: prompted or marked in 30 days state = self.state_for("boot-B") self.assertTrue(state.rebooted) @@ -525,7 +619,9 @@ def test_the_box_installers_replace_the_script_atomically_too(self): def test_a_running_watcher_is_restarted_only_when_its_unit_or_policy_changed(self): sh = self.read("scripts/ssm-agent-session-sync.sh") self.assertIn('put "\\$t/unit" /etc/systemd/system/agent-session-sync@.service 644 && restart=1', sh) - self.assertIn('policy.conf 644 && restart=1', sh) + self.assertIn('systemctl stop agent-session-sync@ubuntu.service', sh) + self.assertLess(sh.index('systemctl stop agent-session-sync@ubuntu.service'), + sh.index('put "\\$t/script" /usr/local/bin/agent-session-sync 755')) self.assertNotRegex(sh, r'script"? /usr/local/bin/agent-session-sync 755 && restart=1') self.assertIn('[ "\\$restart" = 1 ] && systemctl restart agent-session-sync@ubuntu.service', sh) @@ -557,6 +653,108 @@ def test_the_watcher_on_nextjs_does_not_depend_on_a_codex_login(self): self.assertIn("||", guard, "either login is enough") +class AdminInstallOrderingTests(unittest.TestCase): + """Both admin paths fence the old watcher before publishing the new dev-default script.""" + + def run_install(self, kind, stop_ok=True, unchanged=False, policy_write_ok=True, enable_write_ok=True): + d = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, d, True) + bin_, programs, units = d / "bin", d / "programs", d / "units" + for p in (bin_, programs, units): + p.mkdir() + policy = units / "agent-session-sync@ubuntu.service.d" / "policy.conf" + policy.parent.mkdir() + args = "--owner ejc3 --repo dolphin-labs-hq/dolphin-labs --require-clone-marker" + old_args = args if unchanged else "--owner ejc3 --repo dolphin-labs-hq/dolphin-labs" + policy.write_text('[Service]\nEnvironment="ASS_ARGS=%s"\n' % old_args) + (programs / "agent-session-sync").write_text("old script\n") + (units / "agent-session-sync@.service").write_text("unit\n") + active, log = d / "active", d / "log" + active.touch() + + def fake(name, body): + p = bin_ / name + p.write_text("#!/bin/bash\n" + body) + p.chmod(0o755) + + fake("systemctl", f'''case "$1" in +show) echo loaded ;; +stop) echo stop >> {log}; {'rm -f ' + str(active) if stop_ok else 'exit 1'} ;; +start|restart) echo "$1" >> {log}; touch {active} ;; +is-active) test -e {active} ;; +*) exit 0 ;; +esac +''') + fake("mv", 'echo "publish ${@: -1}" >> "$ORDER_LOG"\nexec /usr/bin/mv "$@"\n') + fake("sleep", "exit 0\n") + fake("journalctl", "exit 0\n") + if not enable_write_ok: + fake("cat", "printf '[Service]\\n'; exit 1\n") + tf = (ROOT / "agent-session-sync.tf").read_text() + if kind == "ssm": + sh = (ROOT / "scripts/ssm-agent-session-sync.sh").read_text() + body = sh.split("read -r -d '' remote <