From 43e7e830fd07ce644d061e3bd5038c2581815f75 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:26:22 -0700 Subject: [PATCH] fix(tools): fold ~/.local/bin into the POSIX PATH completion siblings, tests + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slim follow-up to the salvaged #111790: the helper becomes a list-returning sibling of _managed_runtime_path_entries (same shape, same "only when it exists" convention) and loses the Windows check the caller already performs. Why here and not in the Electron remote spawn: propagating the login-shell PATH that locateHermes discovered into `exec env HERMES_DESKTOP=1 … hermes serve` would fix only the Desktop SSH surface; the terminal environment's PATH completion is the seam every thin-PATH launcher (SSH, systemd, launchd, cron) already goes through, so the class closes once. Windows twin out of scope. Tests move to the mirror dir tests/tools/environments/ with an absent-dir control; FAQ documents the terminal PATH composition. Fixes #111778 --- tests/tools/environments/__init__.py | 0 .../test_local_path_completion.py | 41 +++++++++++++++++++ tests/tools/test_local_env_blocklist.py | 22 ---------- tools/environments/local.py | 27 ++++++------ website/docs/reference/faq.md | 2 + 5 files changed, 55 insertions(+), 37 deletions(-) create mode 100644 tests/tools/environments/__init__.py create mode 100644 tests/tools/environments/test_local_path_completion.py diff --git a/tests/tools/environments/__init__.py b/tests/tools/environments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tools/environments/test_local_path_completion.py b/tests/tools/environments/test_local_path_completion.py new file mode 100644 index 0000000000..6593855b46 --- /dev/null +++ b/tests/tools/environments/test_local_path_completion.py @@ -0,0 +1,41 @@ +"""Terminal-subshell PATH completion in ``tools/environments/local.py``. + +A backend started by a non-interactive SSH session, systemd or a GUI launcher +inherits a PATH without ``~/.local/bin`` (only the login shell adds it), so CLIs +installed there were ``command not found`` from the terminal tool (#111778). +""" + +import os +import sys + +import pytest + +from tools.environments import local as local_mod +from tools.environments.local import _append_missing_sane_path_entries, _make_run_env + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX PATH completion only") + + +def test_existing_user_local_bin_appended_after_inherited_entries(monkeypatch, tmp_path): + local_bin = tmp_path / ".local" / "bin" + local_bin.mkdir(parents=True) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: []) + monkeypatch.setattr(local_mod, "_managed_runtime_path_entries", lambda: []) + monkeypatch.setattr(local_mod, "_resolve_hermes_bin_dir", lambda: None) + + entries = _make_run_env({})["PATH"].split(os.pathsep) + + assert entries[:2] == ["/usr/bin", "/bin"] + assert entries.count(str(local_bin)) == 1 + # Already on PATH: position kept, no duplicate appended. + already = _append_missing_sane_path_entries(f"{local_bin}:/usr/bin").split(":") + assert already[0] == str(local_bin) and already.count(str(local_bin)) == 1 + + +def test_missing_user_local_bin_not_appended(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr(local_mod, "_managed_runtime_path_entries", lambda: []) + + assert ".local" not in _append_missing_sane_path_entries("/usr/bin:/bin") diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 48727f4a1e..c0787cb955 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -1594,28 +1594,6 @@ class TestSanePathIncludesHomebrew: for entry in _SANE_PATH.split(os.pathsep): assert entry in path_entries - def test_make_run_env_appends_existing_user_local_bin_on_posix(self, monkeypatch, tmp_path): - """A thin remote SSH PATH can still resolve user-installed CLIs (#111778).""" - from tools.environments import local as local_mod - from tools.environments.local import _make_run_env - - local_bin = tmp_path / ".local" / "bin" - local_bin.mkdir(parents=True) - executable = local_bin / "himalaya" - executable.touch() - executable.chmod(0o755) - monkeypatch.setattr(local_mod, "_git_bash_bin_dirs", lambda: []) - monkeypatch.setattr(local_mod.Path, "home", classmethod(lambda cls: tmp_path)) - with patch.dict(os.environ, {"PATH": "/usr/bin:/bin"}, clear=True): - result = _make_run_env({}) - - if sys.platform == "win32": - assert str(local_bin) not in result["PATH"].split(os.pathsep) - else: - entries = result["PATH"].split(os.pathsep) - assert entries[:2] == ["/usr/bin", "/bin"] - assert str(local_bin) in entries - @pytest.mark.macos_only def test_make_run_env_real_launchd_path_gains_homebrew(self): diff --git a/tools/environments/local.py b/tools/environments/local.py index c30a2fe8af..0f60e46a18 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -552,30 +552,27 @@ def _managed_runtime_path_entries() -> list[str]: return [] -def _existing_user_local_bin_dir() -> str | None: - """Return the POSIX user's ``~/.local/bin`` only when it exists.""" - if _IS_WINDOWS: - return None - candidate = Path.home() / ".local" / "bin" - return str(candidate) if candidate.is_dir() else None +def _user_local_bin_entries() -> list[str]: + """``~/.local/bin`` when it exists — the pip --user / pipx / uv-tool install + target. A backend launched by a non-interactive SSH session, systemd or a GUI + launcher inherits a PATH without it (only the login shell adds it), so CLIs + installed there were ``command not found`` from the terminal tool (#111778).""" + local_bin = Path.home() / ".local" / "bin" + return [str(local_bin)] if local_bin.is_dir() else [] def _append_missing_sane_path_entries(existing_path: str) -> str: """Normalised POSIX PATH with missing sane entries appended: empty entries dropped (shells read them as cwd), duplicates collapsed (first wins), then - missing ``_SANE_PATH``, managed-runtime dirs, and an existing user-local bin - dir appended so inherited/login-shell entries keep precedence. Windows is a - no-op passthrough (native ``;`` PATH untouched).""" + missing ``_SANE_PATH`` / managed-runtime / ``~/.local/bin`` dirs appended so + user entries keep precedence. Windows is a no-op passthrough (native ``;`` + PATH untouched).""" if _IS_WINDOWS: return existing_path # dict preserves first-occurrence order; empty entries dropped. ordered = dict.fromkeys(entry for entry in existing_path.split(":") if entry) - user_local_bin = _existing_user_local_bin_dir() - ordered.update(dict.fromkeys([ - *_SANE_PATH.split(":"), - *_managed_runtime_path_entries(), - *([user_local_bin] if user_local_bin else []), - ])) + ordered.update(dict.fromkeys([*_SANE_PATH.split(":"), *_managed_runtime_path_entries(), + *_user_local_bin_entries()])) return ":".join(ordered) diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 2e7d31b5a6..b09c3d802d 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -179,6 +179,8 @@ terminal: Missing files are skipped silently. Sourcing happens in bash, so files that rely on zsh-only syntax may error — if that's a concern, source just the PATH-setting portion (e.g. nvm's `nvm.sh` directly) rather than the whole rc file. +Independently of the init files, every terminal command's `PATH` is completed with the standard system directories (`/usr/local/bin`, `/opt/homebrew/bin`, …), the Hermes-managed runtime dirs, and `~/.local/bin` when it exists (the `pip --user` / `pipx` / `uv tool` install target) — appended after your own entries, so precedence is unchanged. This covers backends started with a thin non-interactive PATH (systemd, GUI launchers, the Desktop SSH remote backend) without any configuration. + To disable the auto-source behaviour (strict login-shell semantics only): ```yaml