diff --git a/plugin/scripts/te b/plugin/scripts/te index 53c416a..ecc0f17 100755 --- a/plugin/scripts/te +++ b/plugin/scripts/te @@ -32,10 +32,27 @@ if [ "${OS:-}" = Windows_NT ]; then exec "$py" "$dir/te_bootstrap.py" cli "$@" done else - for py in "${TE_PYTHON:-}" python3 python; do - [ -n "$py" ] && command -v "$py" >/dev/null 2>&1 || continue - exec "$py" "$dir/te_bootstrap.py" cli "$@" + # TE_PYTHON is the person's own choice: it runs as named. Otherwise the first python3 on PATH + # can be older than the bootstrap's minimum (macOS /usr/bin/python3 is 3.9, and an agent's + # hook PATH is often not a login shell's), which would make the plugin a silent no-op with a + # newer Python installed: take the first one new enough. With none, run the first one too old + # (the probe exits 1), so the bootstrap still fails open / says what it needs; a name that + # runs no Python (a pyenv shim with no version set exits 127) is passed over. The probe + # skips `site` (-E -S). + if [ -n "${TE_PYTHON:-}" ] && command -v "$TE_PYTHON" >/dev/null 2>&1; then + exec "$TE_PYTHON" "$dir/te_bootstrap.py" cli "$@" + fi + probe='import sys; sys.exit(sys.version_info < (3, 10))' + old= + for py in python3 python3.15 python3.14 python3.13 python3.12 python3.11 python3.10 python; do + command -v "$py" >/dev/null 2>&1 || continue + "$py" -E -S -c "$probe" /dev/null 2>&1 + case $? in + 0) exec "$py" "$dir/te_bootstrap.py" cli "$@" ;; + 1) [ -n "$old" ] || old=$py ;; + esac done + [ -n "$old" ] && exec "$old" "$dir/te_bootstrap.py" cli "$@" fi echo "te: no python3 on PATH (set TE_PYTHON)" >&2 exit 1 diff --git a/plugin/scripts/te-hook b/plugin/scripts/te-hook index da387ea..94da312 100755 --- a/plugin/scripts/te-hook +++ b/plugin/scripts/te-hook @@ -1,5 +1,6 @@ #!/bin/sh -# TokenEconomizer hook launcher: run the hook dispatcher under the first Python 3 on PATH. +# TokenEconomizer hook launcher: run the hook dispatcher under TE_PYTHON, else the first Python +# 3.10+ on PATH. # Fails open -- with no usable Python the hook prints an empty object, which changes nothing. # On Windows (Git Bash), $0 can arrive with backslashes. The Microsoft Store alias under WindowsApps # runs nothing, and every probe of it starts AppXSvc (python/pymanager#396), so a Python outside @@ -35,10 +36,27 @@ if [ "${OS:-}" = Windows_NT ]; then exec "$py" "$dir/te_bootstrap.py" hook "$@" done else - for py in "${TE_PYTHON:-}" python3 python; do - [ -n "$py" ] && command -v "$py" >/dev/null 2>&1 || continue - exec "$py" "$dir/te_bootstrap.py" hook "$@" + # TE_PYTHON is the person's own choice: it runs as named. Otherwise the first python3 on PATH + # can be older than the bootstrap's minimum (macOS /usr/bin/python3 is 3.9, and an agent's + # hook PATH is often not a login shell's), which would make the plugin a silent no-op with a + # newer Python installed: take the first one new enough. With none, run the first one too old + # (the probe exits 1), so the bootstrap still fails open / says what it needs; a name that + # runs no Python (a pyenv shim with no version set exits 127) is passed over. The probe + # skips `site` (-E -S). + if [ -n "${TE_PYTHON:-}" ] && command -v "$TE_PYTHON" >/dev/null 2>&1; then + exec "$TE_PYTHON" "$dir/te_bootstrap.py" hook "$@" + fi + probe='import sys; sys.exit(sys.version_info < (3, 10))' + old= + for py in python3 python3.15 python3.14 python3.13 python3.12 python3.11 python3.10 python; do + command -v "$py" >/dev/null 2>&1 || continue + "$py" -E -S -c "$probe" /dev/null 2>&1 + case $? in + 0) exec "$py" "$dir/te_bootstrap.py" hook "$@" ;; + 1) [ -n "$old" ] || old=$py ;; + esac done + [ -n "$old" ] && exec "$old" "$dir/te_bootstrap.py" hook "$@" fi # Drain stdin with a builtin (no `cat` when PATH is empty), then answer "no change". while IFS= read -r _line; do :; done diff --git a/tests/test_plugin_package.py b/tests/test_plugin_package.py index 011f962..bc99e86 100644 --- a/tests/test_plugin_package.py +++ b/tests/test_plugin_package.py @@ -198,6 +198,67 @@ def test_the_launcher_fails_open_without_python(tmp_path): assert code == 0 and json.loads(out) == {} +def _old_python(path, marker): + # Fails the launcher's version probe (`-E -S -c ...`); anything else it is asked to run, it + # records. + _script(path, f'for a in "$@"; do [ "$a" = -c ] && exit 1; done\n: > "{marker}"\n' + 'while IFS= read -r _l; do :; done\necho "{}"') + + +@pytest.mark.skipif(os.name == "nt", reason="the POSIX launcher path") +def test_the_launcher_skips_a_python3_older_than_the_bootstrap_needs(tmp_path): + old, new = tmp_path / "old" / "python3", tmp_path / "new" / "python3.12" + _old_python(old, tmp_path / "old_ran") + _script(new, f'exec "{sys.executable}" "$@"') + work = tmp_path / "work" + work.mkdir() + code, out, _ = _run_hook(os.path.join(PLUGIN, "scripts", "te-hook"), + {"hook_event_name": "SessionStart", "session_id": "s", + "cwd": str(work), "source": "startup"}, + _hook_env(tmp_path, PATH=f"{old.parent}:{new.parent}:/usr/bin:/bin")) + assert code == 0 and json.loads(out) == {} + assert os.path.isdir(tmp_path / "home" / "repos") # the new Python ran the dispatcher + assert not (tmp_path / "old_ran").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="the POSIX launcher path") +def test_with_only_an_old_python_the_launcher_still_hands_it_the_hook(tmp_path): + old = tmp_path / "old" / "python3" + _old_python(old, tmp_path / "old_ran") + code, out, _ = _run_hook(os.path.join(PLUGIN, "scripts", "te-hook"), + {"hook_event_name": "SessionStart"}, + {"PATH": str(old.parent), + "TOKENECONOMIZER_HOME": str(tmp_path / "home")}) + assert code == 0 and json.loads(out) == {} + assert (tmp_path / "old_ran").exists() # so the bootstrap can fail open itself + + +@pytest.mark.skipif(os.name == "nt", reason="the POSIX launcher path") +def test_a_shim_that_runs_no_python_is_passed_over(tmp_path): + """A pyenv shim with no version set exits 127 and runs nothing: with no other Python on + PATH, the launcher answers no change itself.""" + shim = tmp_path / "shims" / "python3" + _script(shim, 'echo "pyenv: python3: command not found" >&2\nexit 127') + code, out, _ = _run_hook(os.path.join(PLUGIN, "scripts", "te-hook"), + {"hook_event_name": "SessionStart"}, {"PATH": str(shim.parent)}) + assert code == 0 and json.loads(out) == {} + + +@pytest.mark.skipif(os.name == "nt", reason="the POSIX launcher path") +def test_te_python_runs_as_named_even_beside_a_newer_python(tmp_path): + """`TE_PYTHON` is the person's own choice: the launcher runs it without a probe, and the + bootstrap says what it needs if it is too old.""" + old, new = tmp_path / "old" / "python3", tmp_path / "new" / "python3.12" + _old_python(old, tmp_path / "old_ran") + _script(new, f'exec "{sys.executable}" "$@"') + code, out, _ = _run_hook(os.path.join(PLUGIN, "scripts", "te-hook"), + {"hook_event_name": "SessionStart"}, + {"PATH": str(new.parent), "TE_PYTHON": str(old), + "TOKENECONOMIZER_HOME": str(tmp_path / "home")}) + assert code == 0 and json.loads(out) == {} + assert (tmp_path / "old_ran").exists() + + def test_the_bootstrap_answers_no_change_on_an_old_python(tmp_path, monkeypatch): sys.path.insert(0, os.path.join(PLUGIN, "scripts")) try: