diff --git a/CLAUDE.md b/CLAUDE.md index 44bdfc076..a1e7cb772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -475,6 +475,25 @@ gates a merge**, and no seat has to clear one. drift is a reason to re-check the rest. An item already answered is a good outcome: record it and stop. BACKLOG #1448; the evidence is in [`docs/METHOD.md`](docs/METHOD.md). +### A cloud session is a Builder in its own session + +A Claude Code cloud session (`CLAUDE_CODE_REMOTE=true`) holds one seat only: a **Builder in its own +session, on `MEFORORG/MessageFoundry` alone**. **Never attach a second repository**: a session with +several repositories loads hooks from none of them. Its `.git/mefor-coord` belongs to its own clone +and reaches no other session, so `seat.ps1 -Declare`, `mail.ps1`, `alloc.ps1` and `claim.ps1` +refuse there, and the declare rule above does not apply. A brief that needs an ADR number is not a +cloud brief. Cite the brief's item in the commit body, not the subject: the commit-msg claim gate +refuses a code commit whose subject cites a ledger number nobody claimed, and no claim can be taken +there. Commit under the allowlisted git identity the cloud environment sets, or `cla` blocks the PR. + +The GitHub proxy refuses GraphQL, so no `gh pr` and no `gh issue`. Open the PR over REST with +`gh api -X POST repos/MEFORORG/MessageFoundry/pulls -f title=... -f head= -f base=main -F +body=@`, then hand it to the Lander with the `qa` label +(`gh api -X POST repos/MEFORORG/MessageFoundry/issues//labels -f "labels[]=qa"`) and a PR comment, +never with mail. The evidence is probe PR +[2432](https://github.com/MEFORORG/MessageFoundry/pull/2432) (2026-10-19) and fleet wiki note +`20261018T215116801Z-5lqiqo`. + ### The Manager plans, dispatches, and holds the owner's attention - **Plan first, then dispatch.** For anything past a trivial change the Manager produces a plan and diff --git a/scripts/coord/alloc.ps1 b/scripts/coord/alloc.ps1 index 2500cb847..192b80466 100644 --- a/scripts/coord/alloc.ps1 +++ b/scripts/coord/alloc.ps1 @@ -125,6 +125,14 @@ param( $ErrorActionPreference = "Stop" +# A CLAUDE CODE CLOUD SESSION REACHES NO OTHER SESSION THROUGH THIS SCRIPT: its --git-common-dir is +# the VM's own clone, so a write would "succeed" where nobody reads. CLAUDE.md section 5, "A cloud +# session is a Builder in its own session"; tests/test_coord_cloud_refusal.py holds the copies equal. +if ($env:CLAUDE_CODE_REMOTE -eq 'true') { + [Console]::Error.WriteLine('alloc.ps1: refused in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). This VM''s .git/mefor-coord reaches no other session, so nothing was read or recorded.') + exit 3 +} + # ANCHOR ON THE SCRIPT, NOT ON THE CURRENT DIRECTORY (BACKLOG #1060). Unanchored, every `git` call below # resolved against wherever the shell happened to be standing, so an absolute `-File` invocation from # worktree A while intending to commit from worktree B recorded the claim to A. The ledger gate then diff --git a/scripts/coord/claim.ps1 b/scripts/coord/claim.ps1 index 7f395f126..c5ce6cd22 100644 --- a/scripts/coord/claim.ps1 +++ b/scripts/coord/claim.ps1 @@ -82,6 +82,14 @@ param( $ErrorActionPreference = "Stop" +# A CLAUDE CODE CLOUD SESSION REACHES NO OTHER SESSION THROUGH THIS SCRIPT: its --git-common-dir is +# the VM's own clone, so a write would "succeed" where nobody reads. CLAUDE.md section 5, "A cloud +# session is a Builder in its own session"; tests/test_coord_cloud_refusal.py holds the copies equal. +if ($env:CLAUDE_CODE_REMOTE -eq 'true') { + [Console]::Error.WriteLine('claim.ps1: refused in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). This VM''s .git/mefor-coord reaches no other session, so nothing was read or recorded.') + exit 3 +} + # Anchored on the SCRIPT, not the current directory -- the reasoning is written out once, at the head of # alloc.ps1 (BACKLOG #1060). It applies here identically and was found here by inspection rather than by # a second reproduction: this file recorded `worktree = $repo` from the same unanchored call, so an diff --git a/scripts/coord/mail.ps1 b/scripts/coord/mail.ps1 index 6dc84036f..9d6a6cae3 100644 --- a/scripts/coord/mail.ps1 +++ b/scripts/coord/mail.ps1 @@ -134,6 +134,14 @@ param( $ErrorActionPreference = 'Stop' +# A CLAUDE CODE CLOUD SESSION REACHES NO OTHER SESSION THROUGH THIS SCRIPT: its --git-common-dir is +# the VM's own clone, so a write would "succeed" where nobody reads. CLAUDE.md section 5, "A cloud +# session is a Builder in its own session"; tests/test_coord_cloud_refusal.py holds the copies equal. +if ($env:CLAUDE_CODE_REMOTE -eq 'true') { + [Console]::Error.WriteLine('mail.ps1: refused in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). This VM''s .git/mefor-coord reaches no other session, so nothing was read or recorded.') + exit 3 +} + # Send-set arguments without -Send: refuse by name, before anything is read or written. See $Send. if ($PSCmdlet.ParameterSetName -eq 'Send' -and -not $Send) { throw "Send arguments were given without -Send. Nothing was sent. Add -Send and re-run." diff --git a/scripts/coord/seat.ps1 b/scripts/coord/seat.ps1 index b0ed32f51..b8ccbfa40 100644 --- a/scripts/coord/seat.ps1 +++ b/scripts/coord/seat.ps1 @@ -113,6 +113,17 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# A CLAUDE CODE CLOUD SESSION REACHES NO OTHER SESSION THROUGH THIS SCRIPT: its --git-common-dir is +# the VM's own clone, so a write would "succeed" where nobody reads. CLAUDE.md section 5, "A cloud +# session is a Builder in its own session"; tests/test_coord_cloud_refusal.py holds the copies equal. +# Every write verb is refused except the two hook paths, -Record and -Prompt, which must exit 0 on +# every route; the user-scope Stop hook that drives -Record never loads in a cloud session, and the +# SessionStart hook that drives -Prompt skips it there. +if ($env:CLAUDE_CODE_REMOTE -eq 'true' -and $PSCmdlet.ParameterSetName -notin 'Record', 'Prompt') { + [Console]::Error.WriteLine('seat.ps1: refused in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). This VM''s .git/mefor-coord reaches no other session, so nothing was read or recorded.') + exit 3 +} + $SCHEMA = 1 $WRITER = 'seat.ps1/1.0.0' diff --git a/scripts/hooks/seat-declare-prompt.ps1 b/scripts/hooks/seat-declare-prompt.ps1 index 582b8484c..21d640420 100644 --- a/scripts/hooks/seat-declare-prompt.ps1 +++ b/scripts/hooks/seat-declare-prompt.ps1 @@ -61,6 +61,15 @@ param() $ErrorActionPreference = 'Continue' try { + # A CLAUDE CODE CLOUD SESSION HAS NO SEAT REGISTRY TO DECLARE INTO. In a cloud VM --git-common-dir + # resolves to the clone's own .git, so the prompt would send the session to a seat.ps1 -Declare that + # writes where nobody reads -- which is why seat.ps1 -Declare now refuses there. Nothing is stamped + # either, for the same reason. One line instead of the prompt, and still exit 0 (see the header). + if ($env:CLAUDE_CODE_REMOTE -eq 'true') { + Write-Output "[seat] This is a Claude Code cloud session (CLAUDE_CODE_REMOTE=true): a cloud Builder with no seat registry, so do not declare. CLAUDE.md section 5, 'A cloud session is a Builder in its own session', says how it hands its PR over." + exit 0 + } + # Read stdin if a payload is there. SessionStart delivers session_id, and without it seat.ps1 # keys the record by a fallback rather than by the session that is actually starting. It also # delivers cwd, the session's own directory, which this process may not be in. diff --git a/scripts/worktree/ensure-venv.ps1 b/scripts/worktree/ensure-venv.ps1 index ef7165bc4..4170ce60f 100644 --- a/scripts/worktree/ensure-venv.ps1 +++ b/scripts/worktree/ensure-venv.ps1 @@ -40,12 +40,22 @@ param( # session runs this in its own worktree. Resolved to the checkout ROOT via git, so running it # from a subdirectory does the right thing instead of creating a stray .venv there. [string]$Path = $PWD.Path, - [string]$Python = "python", + # The interpreter that creates the venv. Defaults to `python` on Windows. Elsewhere it prefers + # `python3.14`, because the distro `python3` on Ubuntu, which a Claude Code cloud VM runs, is older + # than the >=3.14 pyproject requires and has no `python` beside it; then `python3`. + [string]$Python, [switch]$Sqlserver # also install the [sqlserver] extra ) $ErrorActionPreference = "Stop" +# [Environment]::OSVersion rather than $IsWindows, which Windows PowerShell 5.1 does not define, so that +# host would have read as Linux. +$onWindows = [Environment]::OSVersion.Platform -eq 'Win32NT' +if (-not $Python) { + $Python = if ($onWindows) { "python" } elseif (Get-Command python3.14 -ErrorAction SilentlyContinue) { "python3.14" } else { "python3" } +} + if (-not (Test-Path -LiteralPath $Path)) { throw "No such path: $Path" } # Ask GIT for the checkout root rather than walking parents ourselves. A worktree's root is not @@ -63,7 +73,9 @@ if (-not (Test-Path -LiteralPath (Join-Path $root 'pyproject.toml'))) { } $venv = Join-Path $root ".venv" -$venvPy = Join-Path $venv "Scripts\python.exe" +# A venv puts its interpreter at Scripts\python.exe on Windows and at bin/python everywhere else. The +# Linux layout is what a Claude Code cloud VM gets. +$venvPy = if ($onWindows) { Join-Path $venv "Scripts\python.exe" } else { Join-Path $venv "bin/python" } # Where the venv imports the two packages from, judged by the venv's own interpreter (BACKLOG #2857). # One line per package: " OK " or " BAD ". diff --git a/tests/conftest.py b/tests/conftest.py index 0e9296ea6..d8934b109 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,6 +157,13 @@ def _publish_slot_var(name: str, value: str) -> None: _publish_slot_var("MEFOR_TEST_PORT_BASE", str(20000 + _PORTS_PER_SLOT * _SLOT)) _publish_slot_var("MEFOR_TEST_QSETTINGS_ORG", f"MEFOR-Test-{_SLOT}") +# THE SUITE TESTS THE LOCAL MACHINE, SO IT DOES NOT INHERIT A CLOUD SESSION'S MARKER. A Claude Code +# cloud VM exports CLAUDE_CODE_REMOTE=true, and the coordination scripts refuse every call under it +# (tests/test_coord_cloud_refusal.py). Inherited, that refusal would fail every pwsh-driven test of +# those scripts in a cloud Builder's run for a reason unrelated to the change under test. The tests +# of the refusal set the variable themselves, per subprocess. +os.environ.pop("CLAUDE_CODE_REMOTE", None) + @pytest.fixture(scope="session", autouse=True) def _force_aad_bind_when_requested() -> Iterator[None]: diff --git a/tests/test_coord_cloud_refusal.py b/tests/test_coord_cloud_refusal.py new file mode 100644 index 000000000..45c1e8ee8 --- /dev/null +++ b/tests/test_coord_cloud_refusal.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""The coordination scripts refuse in a Claude Code cloud session instead of writing where nobody reads. + +A cloud VM is its own clone, so ``git rev-parse --git-common-dir`` resolves to that clone's ``.git`` +and ``/mefor-coord`` is a valid directory that no other session ever reads. Before the +guard, ``seat.ps1``, ``mail.ps1``, ``alloc.ps1`` and ``claim.ps1`` would all have "succeeded" into it: +a declaration nobody sees, mail nobody drains, an ADR number the fleet never learns about. + +Each refusal is paired with a control that runs the same call without ``CLAUDE_CODE_REMOTE`` and +must take the normal path, because a refusal test alone also passes against a script that refuses +every call. tests/conftest.py drops the variable from the suite's own environment, so the controls +stay meaningful when the suite itself runs in a cloud VM. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +COORD = ROOT / "scripts" / "coord" +HOOKS = ROOT / "scripts" / "hooks" +TIMEOUT = 120 +REFUSAL_EXIT = 3 +REFUSAL_TAIL = ( + ": refused in a Claude Code cloud session (CLAUDE_CODE_REMOTE=true). This VM's .git/mefor-coord" + " reaches no other session, so nothing was read or recorded." +) +SEAT_DECLARE = ("-Declare", "-Seat", "builder", "-Goal", "cloud refusal test") + +needs_pwsh = pytest.mark.skipif(shutil.which("pwsh") is None, reason="the scripts run under pwsh") +# The normal paths of these scripts are exercised on Windows by their own suites; the controls here +# follow the same platform gate. The refusals fire before anything platform-specific. +needs_windows = pytest.mark.skipif( + os.name != "nt", reason="the scripts' normal paths run on Windows" +) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=TIMEOUT, check=True + ) + + +@pytest.fixture(scope="module") +def _template(tmp_path_factory: pytest.TempPathFactory) -> Path: + """One clone carrying its OWN copy of scripts/coord and scripts/hooks, built once per module.""" + r = tmp_path_factory.mktemp("template") / "repo" + r.mkdir() + _git(r, "init", "-q", "-b", "main") + _git(r, "config", "user.email", "t@example.invalid") + _git(r, "config", "user.name", "t") + # No background maintenance: the commit below can detach one that holds + # .git/objects/maintenance.lock while a test copies the tree, and the copy then fails on a lock + # that vanished mid-walk. Measured on the ubuntu tooling leg, PR 2434. + _git(r, "config", "maintenance.auto", "false") + _git(r, "config", "gc.auto", "0") + shutil.copytree(COORD, r / "scripts" / "coord") + shutil.copytree(HOOKS, r / "scripts" / "hooks") + (r / "f.txt").write_text("x", encoding="utf-8") + _git(r, "add", "f.txt") + _git(r, "commit", "-qm", "base") + return r + + +@pytest.fixture +def repo(tmp_path: Path, _template: Path) -> Path: + """A private copy of the template per test. These scripts anchor on where they live, not on the + cwd, so the copy is what keeps every write out of the real registry and out of a sibling test.""" + r = tmp_path / "repo" + shutil.copytree(_template, r, symlinks=True, ignore=shutil.ignore_patterns("*.lock")) + return r + + +def _coord_dir(repo: Path) -> Path: + return repo / ".git" / "mefor-coord" + + +def _run(repo: Path, script: str, *args: str, cloud: bool) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + if cloud: + env["CLAUDE_CODE_REMOTE"] = "true" + return subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(repo / "scripts" / script), *args], + cwd=str(repo), + env=env, + input="", + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + + +@needs_pwsh +@pytest.mark.parametrize( + ("label", "script", "args"), + [ + pytest.param("seat.ps1", "coord/seat.ps1", SEAT_DECLARE, id="seat-declare"), + pytest.param("seat.ps1", "coord/seat.ps1", ("-Close",), id="seat-close"), + pytest.param("mail.ps1", "coord/mail.ps1", ("-Status",), id="mail-status"), + pytest.param("alloc.ps1", "coord/alloc.ps1", ("-List",), id="alloc-list"), + pytest.param("claim.ps1", "coord/claim.ps1", ("-List",), id="claim-list"), + ], +) +def test_each_coordination_script_refuses_in_a_cloud_session( + repo: Path, label: str, script: str, args: tuple[str, ...] +) -> None: + proc = _run(repo, script, *args, cloud=True) + assert proc.returncode == REFUSAL_EXIT, (proc.stdout, proc.stderr) + assert proc.stderr.strip() == label + REFUSAL_TAIL, proc.stderr + assert proc.stdout == "", proc.stdout + assert not _coord_dir(repo).exists(), "a refused call still created the coordination directory" + + +@needs_pwsh +def test_mail_send_refuses_before_writing_to_an_explicit_mail_root( + repo: Path, tmp_path: Path +) -> None: + """-MailRoot bypasses the git lookup, so this proves the refusal comes before the write itself.""" + root = tmp_path / "mail" + args = ("-Send", "-To", str(repo), "-Body", "x", "-MailRoot", str(root)) + proc = _run(repo, "coord/mail.ps1", *args, cloud=True) + assert proc.returncode == REFUSAL_EXIT, (proc.stdout, proc.stderr) + assert not root.exists() + + +@needs_pwsh +@needs_windows +def test_seat_record_is_not_refused_in_the_cloud(repo: Path) -> None: + """-Record is a Stop-hook path that must exit 0 on every route, so it is exempt.""" + proc = _run(repo, "coord/seat.ps1", "-Record", cloud=True) + assert "refused in a Claude Code cloud session" not in proc.stdout + proc.stderr + assert proc.returncode == 0, (proc.stdout, proc.stderr) + + +@needs_pwsh +@needs_windows +@pytest.mark.parametrize( + ("script", "args"), + [ + pytest.param("coord/mail.ps1", ("-Status",), id="mail-status"), + pytest.param("coord/alloc.ps1", ("-List",), id="alloc-list"), + pytest.param("coord/claim.ps1", ("-List",), id="claim-list"), + ], +) +def test_each_coordination_script_takes_its_normal_path_outside_the_cloud( + repo: Path, script: str, args: tuple[str, ...] +) -> None: + proc = _run(repo, script, *args, cloud=False) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + assert "refused in a Claude Code cloud session" not in proc.stdout + proc.stderr + + +@needs_pwsh +@needs_windows +def test_seat_declare_outside_the_cloud_writes_its_record(repo: Path) -> None: + """The control for the declaration: the record the refusal withholds is really written here.""" + proc = _run(repo, "coord/seat.ps1", *SEAT_DECLARE, cloud=False) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + assert "refused in a Claude Code cloud session" not in proc.stdout + proc.stderr + records = list((_coord_dir(repo) / "seats").rglob("*.json")) + assert records, "seat.ps1 -Declare wrote no record outside the cloud" + + +@needs_pwsh +def test_the_declare_prompt_hook_prints_one_cloud_line_and_exits_zero(repo: Path) -> None: + proc = _run(repo, "hooks/seat-declare-prompt.ps1", cloud=True) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + lines = [ln for ln in proc.stdout.splitlines() if ln.strip()] + assert len(lines) == 1, proc.stdout + assert "cloud Builder with no seat registry" in lines[0] + assert "seat.ps1 -Declare -Seat" not in proc.stdout + assert not _coord_dir(repo).exists(), "the cloud hook stamped a seat record nobody reads" + + +@needs_pwsh +@needs_windows +def test_the_declare_prompt_hook_prints_the_prompt_outside_the_cloud(repo: Path) -> None: + proc = _run(repo, "hooks/seat-declare-prompt.ps1", cloud=False) + assert proc.returncode == 0, (proc.stdout, proc.stderr) + assert "seat.ps1 -Declare -Seat" in proc.stdout + assert "cloud Builder" not in proc.stdout + + +@pytest.mark.parametrize("name", ["seat", "mail", "alloc", "claim"]) +def test_each_script_carries_the_one_refusal_line(name: str) -> None: + """The guard is inlined: alloc.ps1 and claim.ps1 dot-source no helper the other two share. This + holds the four copies to one line without pwsh, so it also runs where the refusal tests skip.""" + text = (COORD / f"{name}.ps1").read_text(encoding="utf-8") + line = f"[Console]::Error.WriteLine('{name}.ps1{REFUSAL_TAIL}')".replace("VM's", "VM''s") + guard = f" {line}\n exit {REFUSAL_EXIT}\n" + assert text.count(guard) == 1, f"{name}.ps1 lost or changed its cloud refusal line" diff --git a/tests/test_worktree_ensure_venv.py b/tests/test_worktree_ensure_venv.py index 1c2c99410..f6183dae4 100644 --- a/tests/test_worktree_ensure_venv.py +++ b/tests/test_worktree_ensure_venv.py @@ -31,10 +31,10 @@ from __future__ import annotations -import os import shutil import subprocess import sys +import sysconfig from pathlib import Path import pytest @@ -43,9 +43,11 @@ _SCRIPT = _REPO / "scripts" / "worktree" / "ensure-venv.ps1" _NEW_PS1 = _REPO / "scripts" / "worktree" / "new.ps1" +# Not Windows-only: a Claude Code cloud Builder runs this script on Linux, so the ubuntu legs, which +# carry pwsh, drive the same paths against a bin/python venv. pytestmark = pytest.mark.skipif( - shutil.which("pwsh") is None or os.name != "nt", - reason="ensure-venv.ps1 is a PowerShell script driven through pwsh on Windows", + shutil.which("pwsh") is None, + reason="ensure-venv.ps1 is a PowerShell script driven through pwsh", ) @@ -95,8 +97,9 @@ def test_it_refuses_a_checkout_that_is_not_this_project(tmp_path: Path) -> None: def _template_venv(tmp_path_factory: pytest.TempPathFactory) -> Path: """One REAL venv with no pip, built once per session; each test gets its own copy of it. - Building one takes seconds. A Windows venv's launcher finds the ``pyvenv.cfg`` beside it, so a - copied tree is a working venv of its own, and no test can change another's.""" + Building one takes seconds. A venv's interpreter finds the ``pyvenv.cfg`` beside it (on Linux + through the copied symlink's own path), so a copied tree is a working venv of its own, and no + test can change another's.""" venv = tmp_path_factory.mktemp("template") / ".venv" subprocess.run( [sys.executable, "-m", "venv", "--without-pip", str(venv)], @@ -122,7 +125,7 @@ def _venv_checkout(tmp_path: Path, template: Path) -> tuple[Path, Path]: (root / name / "__init__.py").write_text(f"# {name} source\n", encoding="utf-8") venv = root / ".venv" shutil.copytree(template, venv, symlinks=True) - site = venv / "Lib" / "site-packages" + site = Path(sysconfig.get_path("purelib", vars={"base": str(venv), "platbase": str(venv)})) (site / "_editable_impl_messagefoundry.pth").write_text(f"{root}\n", encoding="utf-8") return root, site diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 04ac31cf4..23c8eb8db 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -93,6 +93,7 @@ tests/test_coord_claim_liveness.py tests/test_coord_claim_reconcile.py tests/test_coord_claim_refresh.py tests/test_coord_claim_release_history.py +tests/test_coord_cloud_refusal.py tests/test_coord_handoff_clear.py tests/test_coord_handoff_pointer.py tests/test_coord_handoff_report.py