diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 521f2224..f624d3e4 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -22,7 +22,9 @@ # resolving PATH without this session's env (a subprocess with a scrubbed # environment, a `#!/usr/bin/env python3` script) also gets 3.12; # 3. the uv-managed tools rebuilt on 3.12 — mypy and flake8 read the -# interpreter's version, so on 3.11 they judged code against 3.11 rules. +# interpreter's version, so on 3.11 they judged code against 3.11 rules — +# and then repaired, because (2) is what breaks each tool env's own +# `bin/python`, and a rebuild cannot fix a link it is handed. # # What it deliberately does NOT touch: the update-alternatives links under # /usr/bin. Scripts with a literal `#!/usr/bin/python3` shebang follow those, @@ -344,6 +346,69 @@ retool_uv_tools() { done } +# 3b. The link uv's rebuild cannot fix from inside. +# +# uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3` +# was at install time — here `/usr/local/bin/python3`, which leg 2 has already +# replaced with a wrapper that `exec`s the session venv. Every tool env's python +# then resolves its prefix to the VENV: `sys.prefix` is the venv, the tool's own +# site-packages never reaches `sys.path`, and the console script dies with +# `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting +# installed two directories away. +# +# TWO paths reach that state, which is why this runs after leg 3 rather than +# only on the envs leg 3 rebuilt: leg 3 rebuilds a 3.11 tool and hands the new +# env the hijacked path, and leg 2 separately breaks every PRE-EXISTING tool env +# that already pointed there and that leg 3 skips (`is_py312 … && continue`). +# Running here covers both, because leg 2 runs before leg 3. +# +# Measured 2026-08-27, post-bootstrap: mypy, flake8, black, poetry and pyright +# all dead this way; `ruff` survived (a native binary) and `pytest` survived +# (leg 2b points its shim straight at the venv, which has pytest). The +# bootstrap's `--check` called every one of them "3.12 OK", because the +# interpreter they reach IS 3.12 — it is simply the wrong one. A session then +# lints clean by not linting at all, and CI is the thing that finds out. +# +# The fix is one link: a venv's `bin/python` must resolve to a BASE interpreter, +# never to a path this hook hijacks. +# +# This lived in `scripts/session_bootstrap.sh` for one pass, which is the wrong +# home. The bootstrap is what a MULTI-repo session runs; a SINGLE-repo session +# registers this hook and never calls the bootstrap, so it got leg 2's breakage +# and none of the repair. +repair_uv_tools() { + local tools_dir base tool link prefix + base="$("$VENV/bin/python" -c 'import sys, os; print(os.path.join(sys.base_prefix, "bin", "python3.12"))' 2>/dev/null)" || base="" + [ -x "$base" ] || base="$(command -v python3.12 2>/dev/null)" || base="" + [ -x "$base" ] || return 0 + tools_dir="${PYAUTO_UV_TOOLS_DIR:-$(uv tool dir 2>/dev/null || echo "$HOME/.local/share/uv/tools")}" + [ -d "$tools_dir" ] || return 0 + for tool in "$tools_dir"/*/; do + link="${tool}bin/python" + [ -L "$link" ] || continue + # Ask the interpreter where it thinks it lives, rather than tracing the + # link: `/usr/local/bin/python3` is a WRAPPER SCRIPT (a symlink there + # would lose the venv — leg 2's whole note), so `readlink -f` stops at + # the wrapper and reports nothing about the venv behind it. sys.prefix + # is the outcome; anything else is the mechanism. + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || continue + [ -n "$prefix" ] || continue + [ "$prefix" = "${tool%/}" ] && continue # resolves to its own env: correct + # Non-fatal like every other leg: an unwritable tools dir is a warning, + # never a failed session start. + ln -sfn "$base" "$link" || { + log "WARNING: could not repoint $(basename "${tool%/}") at $base" + continue + } + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || prefix="" + if [ "$prefix" = "${tool%/}" ]; then + log "repointed $(basename "${tool%/}") at $base (it resolved into $VENV, not its own env)" + else + log "WARNING: $(basename "${tool%/}") still resolves to ${prefix:-nothing} — it will not run" + fi + done +} + # 4. Honest git history. # # A remote session clones shallow. `git merge-base --is-ancestor` then LIES @@ -461,6 +526,15 @@ if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then return 0 2>/dev/null || exit 0 fi +# Run leg 3b on its own, without a session start. The door +# `scripts/session_bootstrap.sh` knocks on after it has run every repo's hook — +# a subprocess rather than a source, so this script's `set -euo pipefail` never +# leaks into a caller that is contractually "a bootstrap, never a gate". +if [ "${1:-}" = "--repair-uv-tools" ]; then + repair_uv_tools + exit 0 +fi + ensure_full_clone install_workspace_settings @@ -475,6 +549,7 @@ if ensure_venv; then ensure_repo_extras point_system_default retool_uv_tools + repair_uv_tools point_pytest_at_venv point_venv_scripts_at_venv # Every repo in the session registers this hook, so the second copy must not diff --git a/policy/session_start_hook.sh b/policy/session_start_hook.sh index 521f2224..f624d3e4 100755 --- a/policy/session_start_hook.sh +++ b/policy/session_start_hook.sh @@ -22,7 +22,9 @@ # resolving PATH without this session's env (a subprocess with a scrubbed # environment, a `#!/usr/bin/env python3` script) also gets 3.12; # 3. the uv-managed tools rebuilt on 3.12 — mypy and flake8 read the -# interpreter's version, so on 3.11 they judged code against 3.11 rules. +# interpreter's version, so on 3.11 they judged code against 3.11 rules — +# and then repaired, because (2) is what breaks each tool env's own +# `bin/python`, and a rebuild cannot fix a link it is handed. # # What it deliberately does NOT touch: the update-alternatives links under # /usr/bin. Scripts with a literal `#!/usr/bin/python3` shebang follow those, @@ -344,6 +346,69 @@ retool_uv_tools() { done } +# 3b. The link uv's rebuild cannot fix from inside. +# +# uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3` +# was at install time — here `/usr/local/bin/python3`, which leg 2 has already +# replaced with a wrapper that `exec`s the session venv. Every tool env's python +# then resolves its prefix to the VENV: `sys.prefix` is the venv, the tool's own +# site-packages never reaches `sys.path`, and the console script dies with +# `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting +# installed two directories away. +# +# TWO paths reach that state, which is why this runs after leg 3 rather than +# only on the envs leg 3 rebuilt: leg 3 rebuilds a 3.11 tool and hands the new +# env the hijacked path, and leg 2 separately breaks every PRE-EXISTING tool env +# that already pointed there and that leg 3 skips (`is_py312 … && continue`). +# Running here covers both, because leg 2 runs before leg 3. +# +# Measured 2026-08-27, post-bootstrap: mypy, flake8, black, poetry and pyright +# all dead this way; `ruff` survived (a native binary) and `pytest` survived +# (leg 2b points its shim straight at the venv, which has pytest). The +# bootstrap's `--check` called every one of them "3.12 OK", because the +# interpreter they reach IS 3.12 — it is simply the wrong one. A session then +# lints clean by not linting at all, and CI is the thing that finds out. +# +# The fix is one link: a venv's `bin/python` must resolve to a BASE interpreter, +# never to a path this hook hijacks. +# +# This lived in `scripts/session_bootstrap.sh` for one pass, which is the wrong +# home. The bootstrap is what a MULTI-repo session runs; a SINGLE-repo session +# registers this hook and never calls the bootstrap, so it got leg 2's breakage +# and none of the repair. +repair_uv_tools() { + local tools_dir base tool link prefix + base="$("$VENV/bin/python" -c 'import sys, os; print(os.path.join(sys.base_prefix, "bin", "python3.12"))' 2>/dev/null)" || base="" + [ -x "$base" ] || base="$(command -v python3.12 2>/dev/null)" || base="" + [ -x "$base" ] || return 0 + tools_dir="${PYAUTO_UV_TOOLS_DIR:-$(uv tool dir 2>/dev/null || echo "$HOME/.local/share/uv/tools")}" + [ -d "$tools_dir" ] || return 0 + for tool in "$tools_dir"/*/; do + link="${tool}bin/python" + [ -L "$link" ] || continue + # Ask the interpreter where it thinks it lives, rather than tracing the + # link: `/usr/local/bin/python3` is a WRAPPER SCRIPT (a symlink there + # would lose the venv — leg 2's whole note), so `readlink -f` stops at + # the wrapper and reports nothing about the venv behind it. sys.prefix + # is the outcome; anything else is the mechanism. + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || continue + [ -n "$prefix" ] || continue + [ "$prefix" = "${tool%/}" ] && continue # resolves to its own env: correct + # Non-fatal like every other leg: an unwritable tools dir is a warning, + # never a failed session start. + ln -sfn "$base" "$link" || { + log "WARNING: could not repoint $(basename "${tool%/}") at $base" + continue + } + prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || prefix="" + if [ "$prefix" = "${tool%/}" ]; then + log "repointed $(basename "${tool%/}") at $base (it resolved into $VENV, not its own env)" + else + log "WARNING: $(basename "${tool%/}") still resolves to ${prefix:-nothing} — it will not run" + fi + done +} + # 4. Honest git history. # # A remote session clones shallow. `git merge-base --is-ancestor` then LIES @@ -461,6 +526,15 @@ if [ "${PYAUTO_SESSION_DEFINE_ONLY:-}" = "1" ]; then return 0 2>/dev/null || exit 0 fi +# Run leg 3b on its own, without a session start. The door +# `scripts/session_bootstrap.sh` knocks on after it has run every repo's hook — +# a subprocess rather than a source, so this script's `set -euo pipefail` never +# leaks into a caller that is contractually "a bootstrap, never a gate". +if [ "${1:-}" = "--repair-uv-tools" ]; then + repair_uv_tools + exit 0 +fi + ensure_full_clone install_workspace_settings @@ -475,6 +549,7 @@ if ensure_venv; then ensure_repo_extras point_system_default retool_uv_tools + repair_uv_tools point_pytest_at_venv point_venv_scripts_at_venv # Every repo in the session registers this hook, so the second copy must not diff --git a/scripts/session_bootstrap.sh b/scripts/session_bootstrap.sh index 97b64d78..afdefec4 100755 --- a/scripts/session_bootstrap.sh +++ b/scripts/session_bootstrap.sh @@ -98,53 +98,6 @@ extras_state() { return "$rc" } -# The session's OTHER interpreters: uv's tool environments. -# -# uv creates each tool env with `bin/python` as a SYMLINK to whatever `python3` -# was at install time — here `/usr/local/bin/python3`. The hook then repoints -# that same path at the session venv, so every tool env's python now resolves -# its prefix to the VENV: `sys.prefix` is the venv, the tool's own -# site-packages is never on `sys.path`, and the console script dies with -# `ModuleNotFoundError: No module named 'flake8'` — with flake8 sitting -# installed two directories away. -# -# Measured 2026-08-27, post-bootstrap: mypy, flake8, black and poetry all dead -# this way; `ruff` survived (a native binary) and `pytest` survived (its shim -# points straight at the venv, which has pytest). `--check` called every one of -# them "3.12 OK", because the interpreter they reach IS 3.12 — it is simply the -# wrong one. A session then lints clean by not linting at all, and CI is the -# thing that finds out. -# -# The fix is one link: a venv's `bin/python` must resolve to a BASE interpreter, -# never to a path this hook hijacks. -repair_uv_tools() { - local tools_dir base tool link prefix - base="$("$VENV/bin/python" -c 'import sys, os; print(os.path.join(sys.base_prefix, "bin", "python3.12"))' 2>/dev/null)" - [ -x "$base" ] || base="$(command -v python3.12 2>/dev/null)" - [ -x "$base" ] || return 0 - tools_dir="${PYAUTO_UV_TOOLS_DIR:-$(uv tool dir 2>/dev/null || echo "$HOME/.local/share/uv/tools")}" - [ -d "$tools_dir" ] || return 0 - for tool in "$tools_dir"/*/; do - link="${tool}bin/python" - [ -L "$link" ] || continue - # Ask the interpreter where it thinks it lives, rather than tracing the - # link: `/usr/local/bin/python3` is a WRAPPER SCRIPT (a symlink there - # would lose the venv — see the system-default note), so `readlink -f` - # stops at the wrapper and reports nothing about the venv behind it. - # sys.prefix is the outcome; anything else is the mechanism. - prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" || continue - [ -n "$prefix" ] || continue - [ "$prefix" = "${tool%/}" ] && continue # resolves to its own env: correct - ln -sfn "$base" "$link" - prefix="$("$link" -c 'import sys; print(sys.prefix)' 2>/dev/null)" - if [ "$prefix" = "${tool%/}" ]; then - say "repointed $(basename "${tool%/}") at $base (it resolved into $VENV, not its own env)" - else - say "WARNING: $(basename "${tool%/}") still resolves to ${prefix:-nothing} — it will not run" - fi - done -} - shallow_repos() { local root repo out="" root="$(dirname "$MIND_DIR")" @@ -154,12 +107,19 @@ shallow_repos() { printf '%s' "${out# }" } -# A seam for the suite (and for a hand repair): run just this leg. The tools +# A seam for the suite (and for a hand repair): run just that leg. The tools # directory and the venv are both overridable, so the test can build a pair of # real environments and reproduce the breakage exactly. +# +# The leg itself lives in the HOOK — the hook is what a single-repo session runs, +# and that session never reaches this script. Forwarded as a subprocess, not a +# source: the hook runs `set -euo pipefail`, and this script must not, since it +# is a bootstrap and never a gate. `CLAUDE_CODE_REMOTE` is forced for the same +# reason the main path forces it — a caller reaching us from a CLI verb or an +# agent may not have it, and the hook keys off it. if [ "${1:-}" = "--repair-uv-tools" ]; then - repair_uv_tools - exit 0 + [ -x "$HOOK" ] || { say "WARNING: canonical hook missing at $HOOK"; exit 0; } + CLAUDE_CODE_REMOTE=true exec "$HOOK" --repair-uv-tools fi if [ "${1:-}" = "--check" ]; then @@ -290,10 +250,12 @@ for repo in "$root"/*/; do CLAUDE_PROJECT_DIR="${repo%/}" "$hook" || say "WARNING: ${repo%/} hook failed" done -# The hook rebuilds uv's tools on 3.12; this repairs the link that rebuild -# cannot fix from inside, because the path it depends on is one the hook itself -# repoints afterwards. -repair_uv_tools +# The hook rebuilds uv's tools on 3.12 and repairs the link that rebuild cannot +# fix from inside, so every hook run above has already done this. Kept anyway, +# and cheap: it is idempotent, and it is the one path that still covers a hook +# whose python leg exited early (a venv that would not build, or +# PYAUTO_SESSION_SKIP_PYTHON) while uv's tools were already hijacked. +CLAUDE_CODE_REMOTE=true "$HOOK" --repair-uv-tools || say "WARNING: uv tool repair failed" # Make the fix apply to THIS process tree too, not only to shells the session # starts after the env file is read. A caller that sources us gets the PATH; a diff --git a/tests/test_session_bootstrap.py b/tests/test_session_bootstrap.py index da4f253f..5b1b0f84 100644 --- a/tests/test_session_bootstrap.py +++ b/tests/test_session_bootstrap.py @@ -852,3 +852,144 @@ def test_check_fails_a_tool_that_has_the_right_version_but_will_not_run(tmp_path assert "black: 3.12 OK" not in r.stderr, r.stderr assert "will not run" in r.stderr, r.stderr assert r.returncode != 0 + + +# -------------------------------------------------------------------------- +# 8. The repair belongs to the HOOK, not to this script +# +# `repair_uv_tools` shipped in session_bootstrap.sh for one pass. The bootstrap +# is what a MULTI-repo session runs, because that session registers no +# SessionStart hook. A SINGLE-repo session is the mirror image: the hook fires, +# nothing calls the bootstrap, and leg 2 (`point_system_default`) breaks every +# uv tool env on the way past. So the session that got the breakage without the +# repair was the ordinary one. +# +# These drive the leg through the hook — where it now lives — and pin that the +# hook's own flow reaches it. Section 7's tests keep driving the bootstrap seam, +# which now forwards here; that is the point of leaving them unchanged. +# -------------------------------------------------------------------------- + + +def _break_tool_env(tool, session_venv, tmp_path, name="usr-local-python3"): + """Reproduce the breakage: a tool env's python resolving to someone else's + prefix, via the wrapper `/usr/local/bin/python3` actually is.""" + hijacked = tmp_path / name + hijacked.write_text(f'#!/bin/sh\nexec "{session_venv}/bin/python" "$@"\n') + hijacked.chmod(0o755) + link = tool / "bin" / "python" + link.unlink() + link.symlink_to(hijacked) + assert _prefix_of(link) == str(session_venv), "fixture did not reproduce it" + return link + + +def test_the_hook_repairs_a_tool_env_pointed_at_the_session_venv(tmp_path): + """The same breakage as section 7, driven through the hook's own function. + + Section 7 reaches it through `session_bootstrap.sh --repair-uv-tools`, which + is now a forward. This is the leg itself, in the file a single-repo session + actually runs. + """ + session_venv = _venv(tmp_path / "session") + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + link = _break_tool_env(tool, session_venv, tmp_path) + + r = _call_hook_function("repair_uv_tools", venv=session_venv, + extra_env={"PYAUTO_UV_TOOLS_DIR": str(tools)}) + assert r.returncode == 0, r.stderr + assert "repointed widget" in r.stderr, r.stderr + assert _prefix_of(link) == str(tool) + + +def test_the_hook_leaves_a_healthy_tool_env_alone(tmp_path): + session_venv = _venv(tmp_path / "session") + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + before = (tool / "bin" / "python").resolve() + + r = _call_hook_function("repair_uv_tools", venv=session_venv, + extra_env={"PYAUTO_UV_TOOLS_DIR": str(tools)}) + assert r.returncode == 0, r.stderr + assert "repointed" not in r.stderr, r.stderr + assert (tool / "bin" / "python").resolve() == before + + +def test_the_hook_survives_an_unwritable_tools_dir(tmp_path): + """The hook runs `set -euo pipefail`; the bootstrap ran `set -u`. + + Every leg of this hook is contractually non-fatal — "degrades to a logged + warning rather than failing the session start". A leg lifted out of a + laxer script is exactly where that contract gets broken silently, so the + failure path is driven rather than assumed. + """ + session_venv = _venv(tmp_path / "session") + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + _break_tool_env(tool, session_venv, tmp_path) + + (tool / "bin").chmod(0o500) # can read + traverse, cannot replace + try: + r = _call_hook_function("repair_uv_tools", venv=session_venv, + extra_env={"PYAUTO_UV_TOOLS_DIR": str(tools)}) + finally: + (tool / "bin").chmod(0o755) + assert r.returncode == 0, r.stderr + assert "could not repoint widget" in r.stderr, r.stderr + + +def test_a_single_repo_session_start_repairs_the_tool_env_it_just_broke(tmp_path): + """End to end, through the hook's real flow — the bug itself. + + Not a call-order assertion: the hook is run as the harness runs it, with a + session venv it can reuse, and the tool env is checked afterwards. Leg 2 + hijacks the wrapper the tool env points at; the flow must reach leg 3b + before it finishes, or a single-repo session ends with a dead mypy. + """ + home = tmp_path / "home" + (home / ".local" / "bin").mkdir(parents=True) + + # A 3.12 venv for leg 2 to point the system default at, and for the tool + # env to be hijacked into. `ensure_venv` reuses it if it already satisfies + # `venv_ready` and rebuilds it otherwise — either is fine here, because + # what is under test is what the flow does AFTER the interpreter is sorted. + session_venv = tmp_path / "session" + subprocess.run([sys.executable, "-m", "venv", "--system-site-packages", + str(session_venv)], check=True, capture_output=True, timeout=300) + + repo = tmp_path / "FakeOrgan" + (repo / ".claude" / "hooks").mkdir(parents=True) + hook = repo / ".claude" / "hooks" / "session-start.sh" + hook.write_text(CANONICAL_HOOK.read_text()) + hook.chmod(0o755) + + tools = tmp_path / "tools" + tool = _venv(tools / "widget") + link = _break_tool_env(tool, session_venv, tmp_path) + + env = dict(os.environ) + env.update({ + "HOME": str(home), + "CLAUDE_CODE_REMOTE": "true", + "CLAUDE_PROJECT_DIR": str(repo), + "PYAUTO_SESSION_VENV": str(session_venv), + "PYAUTO_SESSION_SYSTEM_BIN": str(tmp_path / "usr-local-bin"), + "PYAUTO_UV_TOOLS_DIR": str(tools), + }) + env.pop("CLAUDE_ENV_FILE", None) + (tmp_path / "usr-local-bin").mkdir() + + r = subprocess.run(["bash", str(hook)], capture_output=True, text=True, + env=env, timeout=600) + # The only honest reason to skip: the hook said it could not produce a 3.12 + # interpreter at all, so the flow never reached the legs under test. Anything + # else is a result, including a failure. + for excuse in ("no Python 3.12 in this container", + "could not build a 3.12 venv"): + if excuse in r.stderr: + pytest.skip(f"no 3.12 interpreter available here: {excuse}") + assert r.returncode == 0, r.stderr + assert _prefix_of(link) == str(tool), ( + "a single-repo session start left the tool env resolving into the venv " + f"— the whole bug.\n{r.stderr[-2000:]}" + )