From eb6d847a3ed1cd88891cc8a57817ba4bb2288e6d Mon Sep 17 00:00:00 2001 From: Colton Campbell <250920182+CoderColton@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:32:00 +0000 Subject: [PATCH 1/3] agent-session-sync: start sessions for each new clone Treat completed allowed-owner clones under the configured roots as session candidates. Keep the interactive marker requirement for linked worktrees and continue excluding internal agent worktrees. Adopt existing unmarked clones on upgrade so the change starts sessions only for clones that appear afterward. --- AGENTS.md | 14 ++++---- agent-session-sync.tf | 4 +-- scripts/agent-session-sync.py | 42 ++++++++++++++++-------- scripts/test-agent-session-sync.py | 51 ++++++++++++++++++++++++++---- 4 files changed, 82 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df031cc3..964b93ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1085,12 +1085,14 @@ 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. 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. - **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 69831c40..6fcd934f 100644 --- a/agent-session-sync.tf +++ b/agent-session-sync.tf @@ -5,8 +5,8 @@ # 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 under the roots, or a 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 # 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 diff --git a/scripts/agent-session-sync.py b/scripts/agent-session-sync.py index 83b560cd..c29fd36c 100644 --- a/scripts/agent-session-sync.py +++ b/scripts/agent-session-sync.py @@ -5,19 +5,22 @@ 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. 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. @@ -158,7 +161,7 @@ def is_allowed(path, owners, repos): def candidates(roots): - """Folders marked for interactive use: checkouts directly under the roots, and their linked worktrees.""" + """Every checkout directly under the roots, and those of its linked worktrees marked for interactive use.""" seen = set() for pattern in roots: for p in glob.glob(pattern): @@ -169,7 +172,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: seen.add(real) yield real for admin in sorted(glob.glob(os.path.join(git, "worktrees", "*"))): @@ -279,6 +282,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 +296,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 +306,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) @@ -312,7 +320,10 @@ def scan_once(cfg, state, launcher, now=None): 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] @@ -364,11 +375,11 @@ def scan_once(cfg, state, launcher, now=None): 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} + state.known[path] = {"ignored": True, "seeded": adopting, "id": ident} continue if not is_finished(path): continue # a clone still running: looked at again next tick - if state.first_run: + 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 +405,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() diff --git a/scripts/test-agent-session-sync.py b/scripts/test-agent-session-sync.py index 9a010fbd..f24125d1 100644 --- a/scripts/test-agent-session-sync.py +++ b/scripts/test-agent-session-sync.py @@ -2,6 +2,7 @@ """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 json import os import shutil import subprocess @@ -123,16 +124,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 +146,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") @@ -180,6 +183,38 @@ 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 OwnerTests(Base): def setUp(self): super().setUp() @@ -333,6 +368,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) From 2582056c0915d976811473091d0d024062275494 Mon Sep 17 00:00:00 2001 From: EJ Date: Tue, 27 Oct 2026 04:40:50 +0000 Subject: [PATCH 2/3] Retry unfinished clones before recording owner decisions A clone seen before its origin was configured was permanently ignored. Wait for the checkout to finish before recording whether its owner is allowed, and cover the incomplete-to-finished transition with a real Git regression. --- scripts/agent-session-sync.py | 4 ++-- scripts/test-agent-session-sync.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/agent-session-sync.py b/scripts/agent-session-sync.py index c29fd36c..9458fab9 100644 --- a/scripts/agent-session-sync.py +++ b/scripts/agent-session-sync.py @@ -374,11 +374,11 @@ 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_finished(path): + continue # a clone still running: looked at again next tick if not is_allowed(path, owners, repos): state.known[path] = {"ignored": True, "seeded": adopting, "id": ident} continue - if not is_finished(path): - continue # a clone still running: looked at again next tick if adopting: state.known[path] = {"claude": True, "codex": True, "seeded": True, "id": ident} continue diff --git a/scripts/test-agent-session-sync.py b/scripts/test-agent-session-sync.py index f24125d1..1cfac81e 100644 --- a/scripts/test-agent-session-sync.py +++ b/scripts/test-agent-session-sync.py @@ -164,6 +164,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) From 0cc33807600e9704216b85f975507fcce8e5f769 Mon Sep 17 00:00:00 2001 From: EJ Date: Tue, 27 Oct 2026 05:00:01 +0000 Subject: [PATCH 3/3] Require explicit clone approval on administration jumpboxes Keep automatic clone sessions on dev boxes while requiring the interactive marker for both agents on both admin jumpboxes. Fence the watcher before changing its policy and code, and publish policy files atomically so a failed update cannot enable unapproved startup hooks. Cover both admin installation paths and failure ordering without restarting interactive sessions. --- AGENTS.md | 8 +- agent-session-sync.tf | 41 ++++++-- jumpbox2-user-data.tf | 10 +- scripts/agent-session-sync.py | 13 ++- scripts/ssm-agent-session-sync.sh | 11 ++- scripts/test-agent-session-sync.py | 154 ++++++++++++++++++++++++++++- 6 files changed, 216 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 964b93ed..b215eaf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1085,14 +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 repository that appears after the watcher first looked. Every checkout directly under - `~/*` or `~/src/*` counts on its own: a clone or a new repository gets a session (owner, 2026-10-27: +- **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 6fcd934f..341df2c5 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: any checkout under the roots, or a 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 9cbbf586..2406d1ac 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 9458fab9..5a63200a 100644 --- a/scripts/agent-session-sync.py +++ b/scripts/agent-session-sync.py @@ -17,6 +17,8 @@ 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. 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 @@ -160,8 +162,8 @@ def is_allowed(path, owners, repos): return owner.lower() in owners or "%s/%s" % (owner, repo) in repos -def candidates(roots): - """Every checkout directly under the roots, and those of its linked worktrees marked for interactive use.""" +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): @@ -172,7 +174,7 @@ def candidates(roots): if not is_main_checkout(real): continue git = os.path.join(real, ".git") - if real not in seen: + 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", "*"))): @@ -316,7 +318,7 @@ 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 = [] @@ -576,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, @@ -610,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 4cd2285d..84b27ae0 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 1cfac81e..be03ee80 100644 --- a/scripts/test-agent-session-sync.py +++ b/scripts/test-agent-session-sync.py @@ -2,6 +2,7 @@ """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 @@ -228,6 +229,32 @@ def test_a_fresh_state_records_the_new_policy_on_its_first_run(self): 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() @@ -331,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) @@ -575,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) @@ -607,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 <