diff --git a/README.md b/README.md index 336782a..0078334 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,14 @@ flowchart LR curl -fsSL https://raw.githubusercontent.com/maxjustships/nomnomcli/main/install.sh | sh ``` -This creates or updates a user-level `nomnom` command. The installer tries `uv tool install` first, -then pipx, then a non-virtualenv Python 3.11+ user-site install. It verifies the actual executable, -`nomnom --version`, and `nomnom doctor --json` in a sanitized user/system-only environment. For -bootstrap verification, it deliberately uses `$HOME/.config`: inherited agent XDG roots and every -`NOMNOM_*` override are ignored. It never opens the meal database, cache, or aliases. +This creates or updates a user-level `nomnom` command in `$HOME/.local/bin`. The installer tries +`uv tool install` first, then pipx, then a non-virtualenv Python 3.11+ user-site install. Every +installer selection, invocation, and executable lookup runs in a target-user sanitized environment: +inherited agent `UV_TOOL_*`, `PIPX_*`, and XDG tool-location overrides are ignored, and uv/pipx are +explicitly directed to target-home defaults. It verifies the actual executable, `nomnom --version`, +and `nomnom doctor --json` in that sanitized user/system-only environment. Bootstrap verification +uses `$HOME/.config`; every `NOMNOM_*` override is ignored. It never opens the meal database, cache, +or aliases. For machine-readable agent output: diff --git a/install.sh b/install.sh index b8d1dbd..3fef088 100644 --- a/install.sh +++ b/install.sh @@ -118,11 +118,133 @@ fail() { exit 1 } +target_login_path() { + # The invoking PATH is the only reliable statement of which system paths + # belong to this target user. Never add a global tool directory here: an + # agent or CI image may have installed uv/pipx there for itself. + _login_base=$(sanitize_target_login_path "${PATH:-}") + _login_shell=${SHELL:-/bin/sh} + if [ ! -x "$_login_shell" ]; then + printf '%s' "$_login_base" + return + fi + _login_environment=$(/usr/bin/env -i \ + HOME="$HOME" \ + USER="${USER:-}" \ + LOGNAME="${LOGNAME:-${USER:-}}" \ + SHELL="$_login_shell" \ + PATH="$_login_base" \ + "$_login_shell" -lc '/usr/bin/env' 2>/dev/null || true) + _login_path=$(printf '%s\n' "$_login_environment" | awk ' + /^PATH=/ { sub(/^PATH=/, ""); path = $0 } + END { printf "%s", path } + ') + _login_path=$(restrict_target_login_path "$_login_path" "$_login_base") + if [ -n "$_login_path" ]; then + printf '%s' "$_login_path" + else + printf '%s' "$_login_base" + fi +} + +append_path() { + _append_current=$1 + _append_dir=$2 + if [ -z "$_append_dir" ]; then + printf '%s' "$_append_current" + elif [ -z "$_append_current" ]; then + printf '%s' "$_append_dir" + else + case ":$_append_current:" in + *":$_append_dir:"*) printf '%s' "$_append_current" ;; + *) printf '%s:%s' "$_append_current" "$_append_dir" ;; + esac + fi +} + +sanitize_target_login_path() { + _sanitize_source=$1 + _sanitize_result="" + _sanitize_old_ifs=$IFS + IFS=: + for _sanitize_dir in $_sanitize_source; do + [ -n "$_sanitize_dir" ] || continue + case "$_sanitize_dir" in + "$HOME/.local/bin"|"$HOME/bin"|\ + /bin|/bin/*|/sbin|/sbin/*|/usr/bin|/usr/bin/*|/usr/sbin|/usr/sbin/*|\ + /usr/local/bin|/usr/local/bin/*|/opt/homebrew/bin|/opt/homebrew/bin/*|\ + /opt/local/bin|/opt/local/bin/*) + _sanitize_result=$(append_path "$_sanitize_result" "$_sanitize_dir") + ;; + esac + done + IFS=$_sanitize_old_ifs + printf '%s' "$_sanitize_result" +} + +restrict_target_login_path() { + _restrict_login_path=$(sanitize_target_login_path "$1") + _restrict_base_path=$2 + _restrict_result="" + _restrict_old_ifs=$IFS + IFS=: + for _restrict_dir in $_restrict_login_path; do + [ -n "$_restrict_dir" ] || continue + case ":$_restrict_base_path:" in + *":$_restrict_dir:"*) + _restrict_result=$(append_path "$_restrict_result" "$_restrict_dir") + continue + ;; + esac + # A target login shell may add these conventional per-user tool + # locations. Do not accept system-wide additions injected by /etc/profile. + case "$_restrict_dir" in + "$HOME/.local/bin"|"$HOME/bin") + _restrict_result=$(append_path "$_restrict_result" "$_restrict_dir") + ;; + esac + done + IFS=$_restrict_old_ifs + printf '%s' "$_restrict_result" +} + +# All installer tool selection, invocation, and discovery use this target-user +# environment. Do not read UV/PIPX/XDG locations from the invoking agent. +TARGET_BIN_DIR="$HOME/.local/bin" +TARGET_UV_TOOL_DIR="$HOME/.local/share/uv/tools" +TARGET_PIPX_HOME="$HOME/.local/share/pipx" +TARGET_LOGIN_PATH=$(target_login_path) +TARGET_COMMAND_PATH=$(append_path "$TARGET_BIN_DIR" "$(sanitize_target_login_path "$TARGET_LOGIN_PATH")") +[ -n "$TARGET_COMMAND_PATH" ] || TARGET_COMMAND_PATH="$TARGET_BIN_DIR:/usr/bin:/bin" + +run_target_environment() { + /usr/bin/env -i \ + HOME="$HOME" \ + USER="${USER:-}" \ + LOGNAME="${LOGNAME:-${USER:-}}" \ + SHELL="${SHELL:-/bin/sh}" \ + PATH="$TARGET_COMMAND_PATH" \ + XDG_CONFIG_HOME="$HOME/.config" \ + XDG_DATA_HOME="$HOME/.local/share" \ + XDG_CACHE_HOME="$HOME/.cache" \ + XDG_STATE_HOME="$HOME/.local/state" \ + PYTHONUSERBASE="$HOME/.local" \ + UV_TOOL_DIR="$TARGET_UV_TOOL_DIR" \ + UV_TOOL_BIN_DIR="$TARGET_BIN_DIR" \ + PIPX_HOME="$TARGET_PIPX_HOME" \ + PIPX_BIN_DIR="$TARGET_BIN_DIR" \ + "$@" +} + +find_target_command() { + PATH="$TARGET_COMMAND_PATH" command -v "$1" 2>/dev/null || true +} + run_install() { if [ "$JSON_OUTPUT" -eq 1 ]; then - "$@" >&2 + run_target_environment "$@" >&2 else - "$@" + run_target_environment "$@" fi } @@ -148,57 +270,46 @@ INSTALL_METHOD="" INSTALL_BIN_DIR="" INSTALL_FAILURES="" -if command -v uv >/dev/null 2>&1; then - if run_install uv tool install --force "$REPO_URL"; then +UV_COMMAND=$(find_target_command uv) +if [ -n "$UV_COMMAND" ]; then + if run_install "$UV_COMMAND" tool install --force "$REPO_URL"; then INSTALL_METHOD="uv" - INSTALL_BIN_DIR=$(uv tool dir --bin 2>/dev/null || true) - [ -n "$INSTALL_BIN_DIR" ] || INSTALL_BIN_DIR=${UV_TOOL_BIN_DIR:-"$HOME/.local/bin"} + INSTALL_BIN_DIR=$(run_target_environment "$UV_COMMAND" tool dir --bin 2>/dev/null || true) + if [ "$INSTALL_BIN_DIR" != "$TARGET_BIN_DIR" ]; then + fail \ + "tool_location_mismatch" \ + "uv reported a tool executable directory outside the target user's default bin." \ + "Ensure uv can use $TARGET_BIN_DIR, then rerun the installer." + fi else INSTALL_FAILURES="uv" note "uv tool install failed; trying the next user-level installer." fi fi -if [ -z "$INSTALL_METHOD" ] && command -v pipx >/dev/null 2>&1; then - if run_install pipx install --force "$REPO_URL"; then +PIPX_COMMAND=$(find_target_command pipx) +if [ -z "$INSTALL_METHOD" ] && [ -n "$PIPX_COMMAND" ]; then + if run_install "$PIPX_COMMAND" install --force "$REPO_URL"; then INSTALL_METHOD="pipx" - INSTALL_BIN_DIR=$(pipx environment --value PIPX_BIN_DIR 2>/dev/null || true) - [ -n "$INSTALL_BIN_DIR" ] || INSTALL_BIN_DIR=${PIPX_BIN_DIR:-"$HOME/.local/bin"} + INSTALL_BIN_DIR=$(run_target_environment "$PIPX_COMMAND" environment --value PIPX_BIN_DIR 2>/dev/null || true) + if [ "$INSTALL_BIN_DIR" != "$TARGET_BIN_DIR" ]; then + fail \ + "tool_location_mismatch" \ + "pipx reported an executable directory outside the target user's default bin." \ + "Ensure pipx can use $TARGET_BIN_DIR, then rerun the installer." + fi else INSTALL_FAILURES="${INSTALL_FAILURES:+$INSTALL_FAILURES,}pipx" note "pipx install failed; trying the system-Python user-site fallback." fi fi -append_path() { - _append_current=$1 - _append_dir=$2 - if [ -z "$_append_dir" ]; then - printf '%s' "$_append_current" - elif [ -z "$_append_current" ]; then - printf '%s' "$_append_dir" - else - case ":$_append_current:" in - *":$_append_dir:"*) printf '%s' "$_append_current" ;; - *) printf '%s:%s' "$_append_current" "$_append_dir" ;; - esac - fi -} - python_search_path() { _python_result="" _python_old_ifs=$IFS IFS=: - for _python_dir in ${PATH:-}; do + for _python_dir in $TARGET_COMMAND_PATH; do [ -n "$_python_dir" ] || continue - if [ -n "${VIRTUAL_ENV:-}" ]; then - case "$_python_dir" in - "$VIRTUAL_ENV"|"$VIRTUAL_ENV"/*) continue ;; - esac - fi - case "$_python_dir" in - *"/.venv"|*"/.venv/"*|*"/venv/"*|*"/.hermes/"*|*"/.codex/"*) continue ;; - esac _python_result=$(append_path "$_python_result" "$_python_dir") done IFS=$_python_old_ifs @@ -218,8 +329,7 @@ find_system_python() { *":$_find_candidate:"*) continue ;; esac _find_seen="${_find_seen:+$_find_seen:}$_find_candidate" - if ! /usr/bin/env -u VIRTUAL_ENV -u PYTHONPATH -u PYTHONHOME \ - "$_find_candidate" -c ' + if ! run_target_environment "$_find_candidate" -c ' import pip # noqa: F401 import sys base_prefix = getattr(sys, "base_prefix", sys.prefix) @@ -227,17 +337,11 @@ raise SystemExit(0 if sys.version_info >= (3, 11) and sys.prefix == base_prefix ' >/dev/null 2>&1; then continue fi - _find_resolved=$(/usr/bin/env -u VIRTUAL_ENV -u PYTHONPATH -u PYTHONHOME \ - "$_find_candidate" -c ' + _find_resolved=$(run_target_environment "$_find_candidate" -c ' import os, sys print(os.path.realpath(sys.executable)) ' 2>/dev/null || true) [ -n "$_find_resolved" ] || _find_resolved=$_find_candidate - if [ -n "${VIRTUAL_ENV:-}" ]; then - case "$_find_resolved" in - "$VIRTUAL_ENV"|"$VIRTUAL_ENV"/*) continue ;; - esac - fi IFS=$_find_old_ifs printf '%s' "$_find_candidate" return 0 @@ -256,31 +360,29 @@ if [ -z "$INSTALL_METHOD" ]; then "Install uv or pipx, or install Python 3.11+ in your normal shell PATH, then rerun this installer." fi note "No isolated tool installer completed; using the system-Python user-site fallback." - if ! run_install /usr/bin/env \ - -u VIRTUAL_ENV -u PYTHONPATH -u PYTHONHOME -u PIP_REQUIRE_VIRTUALENV \ - "$SYSTEM_PYTHON" -m pip install --user --upgrade "$REPO_URL"; then + if ! run_install "$SYSTEM_PYTHON" -m pip install --user --upgrade "$REPO_URL"; then fail \ "installation_failed" \ "The system-Python user-site installation failed${INSTALL_FAILURES:+ after $INSTALL_FAILURES failed}." \ "Review the installer output, ensure Git and network access are available, then rerun." fi INSTALL_METHOD="user-site" - INSTALL_BIN_DIR=$(/usr/bin/env -u VIRTUAL_ENV -u PYTHONPATH -u PYTHONHOME \ - "$SYSTEM_PYTHON" -c ' + INSTALL_BIN_DIR=$(run_target_environment "$SYSTEM_PYTHON" -c ' import sysconfig print(sysconfig.get_path("scripts", scheme=sysconfig.get_preferred_scheme("user"))) ' 2>/dev/null || true) - [ -n "$INSTALL_BIN_DIR" ] || INSTALL_BIN_DIR="$HOME/.local/bin" + if [ "$INSTALL_BIN_DIR" != "$TARGET_BIN_DIR" ]; then + fail \ + "tool_location_mismatch" \ + "The system-Python user-site fallback reported an executable directory outside the target user's default bin." \ + "Ensure Python can use $TARGET_BIN_DIR, then rerun the installer." + fi fi CANDIDATE_DIRS="" for _candidate_dir in \ "$INSTALL_BIN_DIR" \ - "${UV_TOOL_BIN_DIR:-}" \ - "${PIPX_BIN_DIR:-}" \ - "${XDG_BIN_HOME:-}" \ - "$HOME/.local/bin" \ - "$HOME/bin"; do + "$TARGET_BIN_DIR"; do [ -n "$_candidate_dir" ] || continue CANDIDATE_DIRS=$(append_path "$CANDIDATE_DIRS" "$_candidate_dir") done @@ -302,61 +404,11 @@ if [ -z "$EXECUTABLE" ]; then "Inspect $INSTALL_BIN_DIR and rerun with --status-json for structured diagnostics." fi -normal_login_path() { - _login_base="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/opt/local/bin" - _login_shell=${SHELL:-/bin/sh} - if [ ! -x "$_login_shell" ]; then - printf '%s' "$_login_base" - return - fi - _login_environment=$(/usr/bin/env -i \ - HOME="$HOME" \ - USER="${USER:-}" \ - LOGNAME="${LOGNAME:-${USER:-}}" \ - SHELL="$_login_shell" \ - PATH="$_login_base" \ - "$_login_shell" -lc '/usr/bin/env' 2>/dev/null || true) - _login_path=$(printf '%s\n' "$_login_environment" | awk ' - /^PATH=/ { sub(/^PATH=/, ""); path = $0 } - END { printf "%s", path } - ') - if [ -n "$_login_path" ]; then - printf '%s' "$_login_path" - else - printf '%s' "$_login_base" - fi -} - sanitize_normal_path() { - _sanitize_source=$1 - _sanitize_result="" - _sanitize_old_ifs=$IFS - IFS=: - for _sanitize_dir in $_sanitize_source; do - [ -n "$_sanitize_dir" ] || continue - if [ -n "${VIRTUAL_ENV:-}" ]; then - case "$_sanitize_dir" in - "$VIRTUAL_ENV"|"$VIRTUAL_ENV"/*) continue ;; - esac - fi - case "$_sanitize_dir" in - *"/.venv"|*"/.venv/"*|*"/venv/"*|*"/.hermes/"*|*"/.codex/"*) continue ;; - esac - case "$_sanitize_dir" in - "$INSTALL_BIN_DIR"|"${UV_TOOL_BIN_DIR:-__unset__}"|"${PIPX_BIN_DIR:-__unset__}"|\ - "$HOME/.local/bin"|"$HOME/bin"|"${XDG_BIN_HOME:-__unset__}"|\ - /bin|/bin/*|/sbin|/sbin/*|/usr/bin|/usr/bin/*|/usr/sbin|/usr/sbin/*|\ - /usr/local/bin|/usr/local/bin/*|/opt/homebrew/bin|/opt/homebrew/bin/*|\ - /opt/local/bin|/opt/local/bin/*) - _sanitize_result=$(append_path "$_sanitize_result" "$_sanitize_dir") - ;; - esac - done - IFS=$_sanitize_old_ifs - printf '%s' "$_sanitize_result" + sanitize_target_login_path "$1" } -LOGIN_PATH=$(normal_login_path) +LOGIN_PATH=$TARGET_LOGIN_PATH SANITIZED_LOGIN_PATH=$(sanitize_normal_path "$LOGIN_PATH") VERIFY_PATH=$(append_path "$INSTALL_BIN_DIR" "$SANITIZED_LOGIN_PATH") diff --git a/tests/test_install.py b/tests/test_install.py index 4d6824c..7a0da75 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -2,6 +2,7 @@ import json import os +import shlex import subprocess from pathlib import Path @@ -17,6 +18,13 @@ def test_tests_import_checkout_under_review(): assert Path(nomnomcli.__file__).resolve().is_relative_to(ROOT) +def test_installer_does_not_seed_target_login_path_with_global_tool_directories(): + installer = (ROOT / "install.sh").read_text(encoding="utf-8") + + assert '_login_base=$(sanitize_target_login_path "${PATH:-}")' in installer + assert '_login_base="/usr/local/bin:' not in installer + + def _executable(path: Path, content: str) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -109,17 +117,16 @@ def test_installer_prefers_uv_and_reports_provider_state_without_network( tool_bin = tmp_path / "home" / ".local" / "bin" fixture = _nomnom_fixture(tmp_path, usda_ready=usda_ready) environment = _base_environment(tmp_path, harness_bin, fixture) - environment["UV_TOOL_BIN_DIR"] = str(tool_bin) Path(environment["HOME"], ".profile").write_text( 'PATH="$HOME/.local/bin:$PATH"\nexport PATH\n', encoding="utf-8" ) _executable( - harness_bin / "uv", - """#!/bin/sh -printf 'uv %s\n' "$*" >> "$TRACE" + tool_bin / "uv", + f"""#!/bin/sh +printf 'uv %s\\n' "$*" >> {shlex.quote(str(Path(environment["TRACE"])))} if [ "$1 $2 $3" = "tool install --force" ]; then mkdir -p "$UV_TOOL_BIN_DIR" - cp "$FAKE_NOMNOM" "$UV_TOOL_BIN_DIR/nomnom" + cp {shlex.quote(str(fixture))} "$UV_TOOL_BIN_DIR/nomnom" exit 0 fi if [ "$1 $2 $3" = "tool dir --bin" ]; then @@ -127,13 +134,6 @@ def test_installer_prefers_uv_and_reports_provider_state_without_network( exit 0 fi exit 2 -""", - ) - _executable( - harness_bin / "pipx", - """#!/bin/sh -printf 'pipx %s\n' "$*" >> "$TRACE" -exit 99 """, ) @@ -167,6 +167,107 @@ def test_installer_prefers_uv_and_reports_provider_state_without_network( assert before == after +def test_installer_isolates_all_tool_locations_from_poisoned_agent_environment(tmp_path): + """Tool installation and discovery must use only the target user's defaults.""" + harness_bin = tmp_path / "harness-bin" + home = tmp_path / "home" + target_bin = home / ".local" / "bin" + trace = tmp_path / "tool-environment.log" + fixture = _nomnom_fixture(tmp_path, usda_ready=False) + environment = _base_environment(tmp_path, harness_bin, fixture) + poison_root = tmp_path / "agent-tool-state" + poisoned = { + "UV_TOOL_DIR": poison_root / "uv-tools", + "UV_TOOL_BIN_DIR": poison_root / "uv-bin", + "PIPX_HOME": poison_root / "pipx-home", + "PIPX_BIN_DIR": poison_root / "pipx-bin", + "XDG_BIN_HOME": poison_root / "xdg-bin", + "XDG_DATA_HOME": poison_root / "xdg-data", + "XDG_CACHE_HOME": poison_root / "xdg-cache", + "XDG_STATE_HOME": poison_root / "xdg-state", + } + environment.update({name: str(path) for name, path in poisoned.items()}) + environment.update( + { + "XDG_CONFIG_HOME": str(poison_root / "xdg-config"), + "NOMNOM_DB_PATH": str(poison_root / "existing.sqlite3"), + "VIRTUAL_ENV": str(poison_root / "venv"), + } + ) + target_bin.mkdir(parents=True) + Path(environment["HOME"], ".profile").write_text( + 'PATH="$HOME/.local/bin:$PATH"\nexport PATH\n', encoding="utf-8" + ) + _executable( + target_bin / "uv", + f"""#!/bin/sh +{{ + printf 'uv %s\\n' "$*" + for name in \ + UV_TOOL_DIR UV_TOOL_BIN_DIR PIPX_HOME PIPX_BIN_DIR XDG_BIN_HOME XDG_DATA_HOME \ + XDG_CACHE_HOME XDG_STATE_HOME XDG_CONFIG_HOME VIRTUAL_ENV NOMNOM_DB_PATH + do + value=$(printenv "$name" || printf '__UNSET__') + printf '%s=%s\\n' "$name" "$value" + done +}} >> {shlex.quote(str(trace))} +if [ "$1 $2 $3" = "tool install --force" ]; then + mkdir -p "$UV_TOOL_BIN_DIR" + cp {shlex.quote(str(fixture))} "$UV_TOOL_BIN_DIR/nomnom" + exit 0 +fi +if [ "$1 $2 $3" = "tool dir --bin" ]; then + printf '%s\\n' "$UV_TOOL_BIN_DIR" + exit 0 +fi +exit 2 +""", + ) + + result = _run_installer(environment, "--json") + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "installed_base_ready" + assert payload["executable"] == str(target_bin / "nomnom") + assert (target_bin / "nomnom").is_file() + assert not poison_root.exists() + + tool_trace = trace.read_text(encoding="utf-8") + assert tool_trace.count(f"uv tool install --force {REPO_URL}") == 1 + assert tool_trace.count("uv tool dir --bin") == 1 + expected = { + "UV_TOOL_DIR": str(home / ".local" / "share" / "uv" / "tools"), + "UV_TOOL_BIN_DIR": str(target_bin), + "PIPX_HOME": str(home / ".local" / "share" / "pipx"), + "PIPX_BIN_DIR": str(target_bin), + "XDG_BIN_HOME": "__UNSET__", + "XDG_DATA_HOME": str(home / ".local" / "share"), + "XDG_CACHE_HOME": str(home / ".cache"), + "XDG_STATE_HOME": str(home / ".local" / "state"), + "XDG_CONFIG_HOME": str(home / ".config"), + "VIRTUAL_ENV": "__UNSET__", + "NOMNOM_DB_PATH": "__UNSET__", + } + for name, value in expected.items(): + assert tool_trace.count(f"{name}={value}") == 2 + assert str(poison_root) not in tool_trace + + normal_shell = subprocess.run( + ["/bin/sh", "-lc", "command -v nomnom && nomnom --version"], + env={ + "HOME": str(home), + "SHELL": "/bin/sh", + "PATH": "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + }, + check=False, + capture_output=True, + text=True, + ) + assert normal_shell.returncode == 0, normal_shell.stderr + assert normal_shell.stdout.splitlines() == [str(target_bin / "nomnom"), "nomnom 0.4.0"] + + @pytest.mark.parametrize( "doctor_payload", [ @@ -206,16 +307,15 @@ def test_installer_reads_only_top_level_usda_provider_booleans(tmp_path, doctor_ tool_bin = tmp_path / "home" / ".local" / "bin" fixture = _nomnom_fixture(tmp_path, doctor_payload=doctor_payload) environment = _base_environment(tmp_path, harness_bin, fixture) - environment["UV_TOOL_BIN_DIR"] = str(tool_bin) Path(environment["HOME"], ".profile").write_text( 'PATH="$HOME/.local/bin:$PATH"\nexport PATH\n', encoding="utf-8" ) _executable( - harness_bin / "uv", - """#!/bin/sh + tool_bin / "uv", + f"""#!/bin/sh if [ "$1 $2 $3" = "tool install --force" ]; then mkdir -p "$UV_TOOL_BIN_DIR" - cp "$FAKE_NOMNOM" "$UV_TOOL_BIN_DIR/nomnom" + cp {shlex.quote(str(fixture))} "$UV_TOOL_BIN_DIR/nomnom" exit 0 fi if [ "$1 $2 $3" = "tool dir --bin" ]; then @@ -238,17 +338,37 @@ def test_installer_reads_only_top_level_usda_provider_booleans(tmp_path, doctor_ def test_installer_uses_pipx_when_uv_is_unavailable(tmp_path): harness_bin = tmp_path / "harness-bin" - tool_bin = tmp_path / "pipx-bin" + tool_bin = tmp_path / "home" / ".local" / "bin" fixture = _nomnom_fixture(tmp_path, usda_ready=False) environment = _base_environment(tmp_path, harness_bin, fixture) - environment["PIPX_BIN_DIR"] = str(tool_bin) + poison_root = tmp_path / "agent-tool-state" + environment.update( + { + "UV_TOOL_DIR": str(poison_root / "uv-tools"), + "UV_TOOL_BIN_DIR": str(poison_root / "uv-bin"), + "PIPX_HOME": str(poison_root / "pipx-home"), + "PIPX_BIN_DIR": str(poison_root / "pipx-bin"), + "XDG_BIN_HOME": str(poison_root / "xdg-bin"), + "XDG_DATA_HOME": str(poison_root / "xdg-data"), + "XDG_CACHE_HOME": str(poison_root / "xdg-cache"), + "XDG_STATE_HOME": str(poison_root / "xdg-state"), + } + ) + trace_path = shlex.quote(str(Path(environment["TRACE"]))) _executable( - harness_bin / "pipx", - """#!/bin/sh -printf 'pipx %s\n' "$*" >> "$TRACE" + tool_bin / "pipx", + f"""#!/bin/sh +printf 'pipx %s\\n' "$*" >> {trace_path} +for name in \ + UV_TOOL_DIR UV_TOOL_BIN_DIR PIPX_HOME PIPX_BIN_DIR XDG_BIN_HOME XDG_DATA_HOME \ + XDG_CACHE_HOME XDG_STATE_HOME +do + value=$(printenv "$name" || printf '__UNSET__') + printf '%s=%s\\n' "$name" "$value" >> {trace_path} +done if [ "$1 $2 $3" = "install --force git+https://github.com/maxjustships/nomnomcli" ]; then mkdir -p "$PIPX_BIN_DIR" - cp "$FAKE_NOMNOM" "$PIPX_BIN_DIR/nomnom" + cp {shlex.quote(str(fixture))} "$PIPX_BIN_DIR/nomnom" exit 0 fi if [ "$1 $2 $3" = "environment --value PIPX_BIN_DIR" ]; then @@ -274,6 +394,12 @@ def test_installer_uses_pipx_when_uv_is_unavailable(tmp_path): "purpose": "broader no-photo raw/generic food coverage", } assert f"pipx install --force {REPO_URL}" in Path(environment["TRACE"]).read_text() + pipx_trace = Path(environment["TRACE"]).read_text(encoding="utf-8") + assert str(poison_root) not in pipx_trace + assert pipx_trace.count(f"PIPX_HOME={tool_bin.parent / 'share' / 'pipx'}") == 2 + assert pipx_trace.count(f"PIPX_BIN_DIR={tool_bin}") == 2 + assert pipx_trace.count("XDG_BIN_HOME=__UNSET__") == 2 + assert not poison_root.exists() human_result = _run_installer(environment) @@ -284,13 +410,14 @@ def test_installer_uses_pipx_when_uv_is_unavailable(tmp_path): assert "Optional USDA enhancement: run 'nomnom setup'" in human_result.stdout -def test_installer_user_site_fallback_skips_agent_venv_python(tmp_path): +def test_installer_user_site_fallback_ignores_outside_path_installers_and_agent_venv_python( + tmp_path, +): agent_bin = tmp_path / "agent-venv" / "bin" - system_bin = tmp_path / "system-bin" tool_bin = tmp_path / "home" / ".local" / "bin" fixture = _nomnom_fixture(tmp_path, usda_ready=True) - environment = _base_environment(tmp_path, system_bin, fixture) - environment["PATH"] = f"{agent_bin}:{system_bin}:/usr/bin:/bin" + environment = _base_environment(tmp_path, agent_bin, fixture) + environment["PATH"] = f"{agent_bin}:/usr/bin:/bin" environment["VIRTUAL_ENV"] = str(agent_bin.parent) _executable( agent_bin / "python3", @@ -300,9 +427,9 @@ def test_installer_user_site_fallback_skips_agent_venv_python(tmp_path): """, ) _executable( - system_bin / "python3.11", - """#!/bin/sh -printf 'system-python %s\n' "$*" >> "$TRACE" + tool_bin / "python3.11", + f"""#!/bin/sh +printf 'system-python %s\\n' "$*" >> {shlex.quote(str(Path(environment["TRACE"])))} if [ "$1" = "-c" ]; then case "$2" in *sysconfig*) printf '%s\n' "$HOME/.local/bin" ;; @@ -312,7 +439,7 @@ def test_installer_user_site_fallback_skips_agent_venv_python(tmp_path): fi if [ "$1 $2" = "-m pip" ]; then mkdir -p "$HOME/.local/bin" - cp "$FAKE_NOMNOM" "$HOME/.local/bin/nomnom" + cp {shlex.quote(str(fixture))} "$HOME/.local/bin/nomnom" exit 0 fi exit 2 @@ -326,6 +453,8 @@ def test_installer_user_site_fallback_skips_agent_venv_python(tmp_path): assert payload["executable"] == str(tool_bin / "nomnom") trace = Path(environment["TRACE"]).read_text(encoding="utf-8") assert "agent-python -m pip" not in trace + assert "uv " not in trace + assert "pipx " not in trace assert f"system-python -m pip install --user --upgrade {REPO_URL}" in trace @@ -394,7 +523,6 @@ def test_installer_verification_ignores_agent_xdg_and_nomnom_environment(tmp_pat environment = _base_environment(tmp_path, harness_bin, fixture) environment.update( { - "UV_TOOL_BIN_DIR": str(tool_bin), "XDG_CONFIG_HOME": str(tmp_path / "agent-config"), "XDG_CACHE_HOME": str(tmp_path / "agent-cache"), "XDG_DATA_HOME": str(tmp_path / "agent-data"), @@ -414,11 +542,11 @@ def test_installer_verification_ignores_agent_xdg_and_nomnom_environment(tmp_pat 'PATH="$HOME/.local/bin:$PATH"\nexport PATH\n', encoding="utf-8" ) _executable( - harness_bin / "uv", - """#!/bin/sh + tool_bin / "uv", + f"""#!/bin/sh if [ "$1 $2 $3" = "tool install --force" ]; then mkdir -p "$UV_TOOL_BIN_DIR" - cp "$FAKE_NOMNOM" "$UV_TOOL_BIN_DIR/nomnom" + cp {shlex.quote(str(fixture))} "$UV_TOOL_BIN_DIR/nomnom" exit 0 fi if [ "$1 $2 $3" = "tool dir --bin" ]; then