diff --git a/.claude/settings.json b/.claude/settings.json index f930950ff..8db7d1075 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -82,6 +82,13 @@ ], "timeout": 20, "statusMessage": "Injecting this worktree's role card" + }, + { + "type": "command", + "shell": "bash", + "command": "[ \"${CLAUDE_CODE_REMOTE:-}\" = true ] || exit 0; bash \"${CLAUDE_PROJECT_DIR}/scripts/hooks/cloud-setup.sh\"", + "timeout": 300, + "statusMessage": "Cloud session only: installing pwsh and Python 3.14, setting the git identity" } ] } diff --git a/scripts/hooks/cloud-setup.sh b/scripts/hooks/cloud-setup.sh new file mode 100755 index 000000000..57e8e17bb --- /dev/null +++ b/scripts/hooks/cloud-setup.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +# +# SessionStart setup for a Claude Code CLOUD session. A no-op everywhere else. +# +# WHY THIS IS IN THE REPOSITORY AND NOT IN A CLOUD ENVIRONMENT'S SETUP SCRIPT. A setup script lives +# in one claude.ai account's environment settings, entered by hand in the UI, and this project's +# sessions run under several separate accounts. The repository's .claude/settings.json is the one +# thing every cloud session clones, so a hook wired there covers every account with no per-account +# step. The cost is that it runs on every session start, not once per cached image. Measured on a +# probe session on 2026-10-19 (MEFORORG/MessageFoundry PR 2432): the pwsh install took 17 s and the +# Python 3.14 install 3 s, and both are skipped once present. +# +# WHAT A CLOUD SESSION LACKS, AND WHAT THAT COSTS. The image ships no pwsh, so every other hook in +# .claude/settings.json fails to start. A hook that cannot start is a non-blocking error, so the +# blanket-stage, API-burn and filesystem-scan guards are OFF while the tool calls run. The image's +# default Python is 3.13 and the project requires 3.14. Its commits are made as an identity the +# required `cla` check refuses (wiki note 20261002T125230275Z-0glfkb, PR 1926). +# +# LOUD, NEVER BLOCKING. Each failure prints a line naming what is now off, and the hook still exits +# 0: a SessionStart hook cannot stop a session, and a non-zero exit would only hide this output. +# +# THE GATE IS ALSO INLINE IN settings.json, so a local session never reads this file. The check +# here is the second copy, for anyone who runs the file by hand. + +[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 + +say() { printf '[cloud-setup] %s\n' "$*"; } + +root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}" +if [ -z "$root" ] || [ ! -f "$root/pyproject.toml" ]; then + say "NOT RUN: no checkout found (CLAUDE_PROJECT_DIR='${CLAUDE_PROJECT_DIR:-}'). Every repo guard is OFF in this session." + exit 0 +fi +cd "$root" || exit 0 + +# Run a step with its output kept out of the context unless it fails. +quiet() { + local log + log="$(mktemp)" + if "$@" >"$log" 2>&1; then rm -f "$log"; return 0; fi + tail -n 5 "$log" | sed 's/^/[cloud-setup] /' + rm -f "$log" + return 1 +} + +install_pwsh() { + local deb + deb="$(mktemp --suffix=.deb)" + # packages.microsoft.com is on the cloud environment's default Trusted allowlist. + curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb -o "$deb" && + dpkg -i "$deb" && + apt-get update -qq && + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq powershell + local rc=$? + rm -f "$deb" + return $rc +} + +pwsh_new=0 +if command -v pwsh >/dev/null 2>&1; then + : +elif quiet install_pwsh && command -v pwsh >/dev/null 2>&1; then + pwsh_new=1 + say "installed pwsh $(pwsh -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>/dev/null)" +else + say "PWSH INSTALL FAILED. Every hook in .claude/settings.json fails to start in this session, so the git-stage, API-burn and fs-scan guards are OFF. Do not run git add -A or a gh polling loop." +fi + +py="" +if quiet uv python install 3.14; then + py="$(uv python find 3.14 2>/dev/null)" +fi +if [ -z "$py" ]; then + say "PYTHON 3.14 INSTALL FAILED. The project requires 3.14; pytest and mypy will not run." +fi + +# The default cloud identity is not a GitHub account, so the required `cla` check refuses it. Commit +# as the GitHub account this session pushes as, through its noreply address: never a fixed name, so +# whoever runs the session is who the commits and the cla check name. Set for this clone only. +gh_user="$(gh api user --jq '"\(.id)\t\(.login)\t\(.name // .login)"' 2>/dev/null)" +if [ -n "$gh_user" ]; then + IFS=$'\t' read -r gh_id gh_login gh_name <<<"$gh_user" + git config user.name "$gh_name" + git config user.email "${gh_id}+${gh_login}@users.noreply.github.com" +else + say "git identity left as the session set it: gh api user failed. The cla check will refuse these commits." +fi + +# The venv is NOT built here. A full install runs for minutes, and a hook that reaches its timeout +# loses all of its output, including the warnings above. Build it as the first step of the work. +if [ -n "$py" ]; then + say "no venv yet. Before running checks: uv venv --python 3.14 .venv, then uv pip install --python .venv/bin/python with the install line in scripts/worktree/ensure-venv.ps1 (that script itself does not run on Linux yet)." +fi + +# Hooks for one event start together, so on a fresh VM the role-card hook ran before pwsh existed +# and injected nothing. Run it again only then; on a resumed VM it already ran and would repeat. +# It resolves the seat itself (KORUS_SEAT, set on the cloud environment); nothing here supplies one. +if [ "$pwsh_new" = 1 ]; then + pwsh -NoProfile -NonInteractive -File scripts/hooks/role-card-inject.ps1 -WorktreeRoot "$root" list[tuple[str, dict[str, Any]]] def _repo_script_refs(handler: dict[str, Any]) -> list[str]: - """Every token in a handler that names a file under the repo's script trees.""" - tokens = [handler.get("command", ""), *handler.get("args", [])] - return [t for t in tokens if isinstance(t, str) and (".ps1" in t or ".py" in t)] + """Every token in a handler that names a file under the repo's script trees. + + A shell-form handler (no `args`) carries its script inside one command string, so that string + is split as the shell would split it. Unsplit, the token starts with whatever precedes the + path, and the script would be invisible to the anchoring and existence checks below. + """ + command = handler.get("command", "") + if "args" in handler or not isinstance(command, str): + tokens = [command, *handler.get("args", [])] + else: + try: + tokens = shlex.split(command) + except ValueError: + # Unbalanced quoting: keep the whole string, so the anchoring check names this + # handler instead of every contract test dying in a traceback. + tokens = [command] + return [t for t in tokens if isinstance(t, str) and any(x in t for x in (".ps1", ".py", ".sh"))] def _unanchored_refs(settings: dict[str, Any]) -> list[str]: @@ -239,7 +254,8 @@ def _spawn_tools_the_matcher_misses(settings: dict[str, Any]) -> list[str]: def _hook_scripts() -> list[str]: - return sorted(p.name for p in (_ROOT / "scripts" / "hooks").glob("*.ps1")) + hooks = _ROOT / "scripts" / "hooks" + return sorted(p.name for pattern in ("*.ps1", "*.sh") for p in hooks.glob(pattern)) def _installer_wired(root: Path | None = None) -> set[str]: @@ -450,6 +466,20 @@ def _unclassified_for_planted(settings: dict[str, Any]) -> list[str]: _unanchored_refs, "bare relative hook path", ), + ( + # Shell form carries the path inside one command string. Unsplit, the token starts with + # `bash`, and before `.sh` was scanned at all this reference was invisible. + { + "permissions": {"deny": []}, + "hooks": { + "SessionStart": [ + {"hooks": [{"shell": "bash", "command": "bash scripts/hooks/x.sh"}]} + ] + }, + }, + _unanchored_refs, + "bare relative path in a shell-form hook", + ), ( {"permissions": {"deny": ["Read(./.env)"]}, "hooks": {}}, _dot_anchored_denies, @@ -496,7 +526,13 @@ def _unclassified_for_planted(settings: dict[str, Any]) -> list[str]: "matcher that is an exact-name list rather than the regex it resembles", ), ], - ids=["unanchored-hook", "dot-anchored-deny", "unwired-hook-script", "exact-list-matcher"], + ids=[ + "unanchored-hook", + "unanchored-shell-form-hook", + "dot-anchored-deny", + "unwired-hook-script", + "exact-list-matcher", + ], ) def test_the_checks_can_actually_fail(planted: dict[str, Any], checker: Any, label: str) -> None: """A guard that cannot be shown to fail is not a guard. diff --git a/tests/test_cloud_setup_hook.py b/tests/test_cloud_setup_hook.py new file mode 100644 index 000000000..58fbe42d0 --- /dev/null +++ b/tests/test_cloud_setup_hook.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""The cloud-only SessionStart hook: wired once, gated on CLAUDE_CODE_REMOTE, and inert locally. + +The hook installs pwsh and Python 3.14 in a Claude Code cloud VM. The property that matters most +is the one nobody would notice breaking: on a LOCAL session it must do nothing at all. It is wired +in the tracked settings file, so every session on every account runs its command, and a broken +gate would run apt-get on a developer box. So the gate is tested as wired, with a control proving +the same command DOES run the script once the variable is set. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +_ROOT = Path(__file__).resolve().parents[1] +_SETTINGS = _ROOT / ".claude" / "settings.json" +_SCRIPT = _ROOT / "scripts" / "hooks" / "cloud-setup.sh" +_BASH = shutil.which("bash") + +needs_bash = pytest.mark.skipif(_BASH is None, reason="bash is not on PATH") + + +def _cloud_handlers() -> list[dict[str, Any]]: + settings = json.loads(_SETTINGS.read_text(encoding="utf-8")) + return [ + handler + for group in settings["hooks"]["SessionStart"] + for handler in group.get("hooks", []) + if "cloud-setup.sh" in handler.get("command", "") + ] + + +def _run( + command: list[str], env_extra: dict[str, str], cwd: Path +) -> subprocess.CompletedProcess[str]: + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CODE_REMOTE"} + env.update(env_extra) + return subprocess.run( + command, cwd=cwd, env=env, capture_output=True, text=True, timeout=60, check=False + ) + + +def test_the_hook_is_wired_exactly_once_in_shell_form_with_its_gate() -> None: + handlers = _cloud_handlers() + assert len(handlers) == 1, ( + f"expected one SessionStart handler for cloud-setup.sh, got {handlers}" + ) + handler = handlers[0] + # Exec form would need `bash` on PATH, and on the Windows dev box it is not: the hook would then + # fail to start in EVERY local session. `shell: bash` is resolved by Claude Code itself. + assert handler.get("shell") == "bash" + assert "args" not in handler + assert handler["command"].startswith('[ "${CLAUDE_CODE_REMOTE:-}" = true ] || exit 0;') + # Installs on a fresh VM take tens of seconds. A stated budget keeps a slow mirror from being + # mistaken for a hang; the script's header has the measured times. + assert handler.get("timeout", 0) >= 120 + + +def _planted_project(tmp_path: Path) -> Path: + """A project dir whose cloud-setup.sh only reports that it ran.""" + hooks = tmp_path / "scripts" / "hooks" + hooks.mkdir(parents=True) + (hooks / "cloud-setup.sh").write_text("echo PLANTED-RAN\n", encoding="utf-8") + return tmp_path + + +@needs_bash +@pytest.mark.parametrize(("remote", "runs"), [(None, False), ("false", False), ("true", True)]) +def test_the_wired_command_runs_the_script_only_in_the_cloud( + tmp_path: Path, remote: str | None, runs: bool +) -> None: + assert _BASH is not None + project = _planted_project(tmp_path) + env = {"CLAUDE_PROJECT_DIR": project.as_posix()} + if remote is not None: + env["CLAUDE_CODE_REMOTE"] = remote + result = _run([_BASH, "-c", _cloud_handlers()[0]["command"]], env, project) + assert result.returncode == 0, result.stderr + # The "true" row is the control: without it, a command that never ran anything would pass the + # two local rows. + assert ("PLANTED-RAN" in result.stdout) is runs, result.stdout + + +@needs_bash +def test_the_script_is_a_silent_no_op_outside_the_cloud(tmp_path: Path) -> None: + assert _BASH is not None + result = _run([_BASH, str(_SCRIPT)], {"CLAUDE_PROJECT_DIR": tmp_path.as_posix()}, tmp_path) + assert result.returncode == 0, result.stderr + assert result.stdout == "" + + +@needs_bash +def test_the_script_refuses_loudly_without_a_checkout_and_installs_nothing(tmp_path: Path) -> None: + assert _BASH is not None + result = _run( + [_BASH, str(_SCRIPT)], + {"CLAUDE_CODE_REMOTE": "true", "CLAUDE_PROJECT_DIR": tmp_path.as_posix()}, + tmp_path, + ) + assert result.returncode == 0, result.stderr + assert "NOT RUN: no checkout found" in result.stdout + assert "guard" in result.stdout + + +def _stub_tools(tmp_path: Path, gh_output: str | None) -> Path: + """`pwsh`, `uv` and `gh` stand-ins, so the cloud path runs with no install and no network.""" + stubs = tmp_path / "stubs" + stubs.mkdir() + gh_body = f"printf '%s\\n' '{gh_output}'" if gh_output is not None else "exit 1" + bodies = { + "pwsh": "exit 0", + "uv": '[ "$1 $2" = "python find" ] && echo /stub/python3.14; exit 0', + "gh": gh_body, + } + for name, body in bodies.items(): + f = stubs / name + f.write_text(f"#!/usr/bin/env bash\n{body}\n", encoding="utf-8", newline="\n") + f.chmod(0o755) + return stubs + + +def _checkout(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "pyproject.toml").write_text("", encoding="utf-8") + subprocess.run(["git", "init", "-q", str(repo)], check=True) + return repo + + +def _local_git_config(repo: Path, key: str) -> str | None: + out = subprocess.run( + ["git", "-C", str(repo), "config", "--local", "--get", key], + capture_output=True, + text=True, + check=False, + ) + return out.stdout.strip() if out.returncode == 0 else None + + +@needs_bash +def test_the_identity_is_the_pushing_accounts_own_noreply(tmp_path: Path) -> None: + assert _BASH is not None + repo = _checkout(tmp_path) + stubs = _stub_tools(tmp_path, "12345\tjdoe\tJane Doe") + env = { + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": repo.as_posix(), + "PATH": str(stubs) + os.pathsep + os.environ.get("PATH", ""), + } + result = _run([_BASH, str(_SCRIPT)], env, repo) + assert result.returncode == 0, result.stderr + assert _local_git_config(repo, "user.name") == "Jane Doe", result.stdout + assert _local_git_config(repo, "user.email") == "12345+jdoe@users.noreply.github.com" + + +@needs_bash +def test_a_failed_account_lookup_sets_no_identity_and_says_so(tmp_path: Path) -> None: + assert _BASH is not None + repo = _checkout(tmp_path) + stubs = _stub_tools(tmp_path, None) + env = { + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": repo.as_posix(), + "PATH": str(stubs) + os.pathsep + os.environ.get("PATH", ""), + } + result = _run([_BASH, str(_SCRIPT)], env, repo) + assert result.returncode == 0, result.stderr + assert "gh api user failed" in result.stdout + assert _local_git_config(repo, "user.email") is None diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 04ac31cf4..d7a1b877c 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -86,6 +86,7 @@ tests/test_hook_prose_folding.py tests/test_hook_prose_folding_push_ledger.py tests/test_claude_section_citations.py tests/test_claude_settings_contract.py +tests/test_cloud_setup_hook.py tests/test_collision_gate.py tests/test_coord_box_activity.py tests/test_coord_claim_adjudicate.py