fix(tools): fold ~/.local/bin into the POSIX PATH completion siblings, tests + docs

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
This commit is contained in:
teknium1
2026-09-15 12:26:22 -07:00
committed by Teknium
parent c68e306ea4
commit 43e7e830fd
5 changed files with 55 additions and 37 deletions
@@ -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")
-22
View File
@@ -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):
+12 -15
View File
@@ -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)
+2
View File
@@ -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