diff --git a/contributors/emails/med.hamman9@gmail.com b/contributors/emails/med.hamman9@gmail.com new file mode 100644 index 0000000000..d4b561a8a7 --- /dev/null +++ b/contributors/emails/med.hamman9@gmail.com @@ -0,0 +1 @@ +zavrenn diff --git a/scripts/install.sh b/scripts/install.sh index 2289001f04..c1e73f1d26 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1573,6 +1573,30 @@ setup_venv() { log_success "Virtual environment ready (Python $PYTHON_VERSION)" } +run_locked_uv_sync() { + # Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG + # (#21269). A locked project sync is different: uv.lock records resolver + # settings from this checkout's [tool.uv], so hiding pyproject.toml makes + # uv 0.12+ reject the valid lock. Re-enable project discovery only for + # this subprocess while redirecting user/system config lookups to an empty + # directory. Keep HOME unchanged so caches, credentials, and git continue + # to work normally. + local project_env="$1" + local isolated_uv_config + local sync_rc + isolated_uv_config="$(mktemp -d)" || return 1 + + ( + unset UV_NO_CONFIG UV_CONFIG_FILE + export XDG_CONFIG_HOME="$isolated_uv_config" + export XDG_CONFIG_DIRS="$isolated_uv_config" + UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked + ) + sync_rc=$? + rmdir "$isolated_uv_config" 2>/dev/null || true + return "$sync_rc" +} + install_deps() { log_info "Installing dependencies..." @@ -1712,21 +1736,19 @@ install_deps() { # uv's own progress UI handles TTY detection and downgrades # gracefully when stdout/stderr aren't terminals. # - # Strip UV_NO_CONFIG for this one invocation. The global export at - # script start (the #21269 sudo -u hygiene guard) also hides the - # project's own [tool.uv] policy — exclude-newer and its package - # exemptions — from uv. The resolver then runs under a different - # policy than the one uv.lock was resolved under, and --locked - # turns that mismatch fatal: - # error: The lockfile at `uv.lock` needs to be updated, but - # `--locked` was provided. - # Every fresh install then falls through to the non-hash-verified - # PyPI fallback tiers, defeating the point of Tier 0. Runtime code - # already strips UV_NO_CONFIG before its own locked syncs for the - # same reason (hermes_cli/managed_uv.py, "Locked sync must see - # project [tool.uv] exclude-newer"). The export stays in effect for - # every other uv call in this script. - if env -u UV_NO_CONFIG UV_PROJECT_ENVIRONMENT="$INSTALL_DIR/venv" $UV_CMD sync --extra all --locked; then + # Run the Tier-0 locked sync via run_locked_uv_sync: the global + # UV_NO_CONFIG export at script start (the #21269 sudo -u hygiene + # guard) also hides the project's own [tool.uv] policy — + # exclude-newer, its package exemptions, and override-dependencies — + # from uv. The resolver then runs under a different policy than the + # one uv.lock was resolved under, and --locked turns that mismatch + # fatal, so every fresh install fell through to the non-hash-verified + # PyPI tiers. The helper re-enables project config discovery for this + # one subprocess while keeping ambient user/system uv config hidden + # (redirected to an empty XDG dir), preserving the #21269 guarantee. + # Runtime code does the same before its locked syncs + # (hermes_cli/managed_uv.py). + if run_locked_uv_sync "$INSTALL_DIR/venv"; then log_success "Main package installed (hash-verified via uv.lock)" log_success "All dependencies installed" return 0 diff --git a/setup-hermes.sh b/setup-hermes.sh index 1706201055..0358bf1f7b 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -188,6 +188,30 @@ SETUP_PYTHON="$SCRIPT_DIR/venv/bin/python" # Dependencies # ============================================================================ +run_locked_uv_sync() { + # Bootstrap uv calls stay isolated from ambient config via UV_NO_CONFIG + # (#21269). A locked project sync is different: uv.lock records resolver + # settings from this checkout's [tool.uv], so hiding pyproject.toml makes + # uv 0.12+ reject the valid lock. Re-enable project discovery only for + # this subprocess while redirecting user/system config lookups to an empty + # directory. Keep HOME unchanged so caches, credentials, and git continue + # to work normally. + local project_env="$1" + local isolated_uv_config + local sync_rc + isolated_uv_config="$(mktemp -d)" || return 1 + + ( + unset UV_NO_CONFIG UV_CONFIG_FILE + export XDG_CONFIG_HOME="$isolated_uv_config" + export XDG_CONFIG_DIRS="$isolated_uv_config" + UV_PROJECT_ENVIRONMENT="$project_env" $UV_CMD sync --extra all --locked + ) + sync_rc=$? + rmdir "$isolated_uv_config" 2>/dev/null || true + return "$sync_rc" +} + echo -e "${CYAN}→${NC} Installing dependencies..." if is_termux; then @@ -251,7 +275,7 @@ else # at first use. # Also: stream stderr through directly so the user sees uv's # progress UI instead of staring at a frozen prompt. - if UV_PROJECT_ENVIRONMENT="$SCRIPT_DIR/venv" $UV_CMD sync --extra all --locked; then + if run_locked_uv_sync "$SCRIPT_DIR/venv"; then echo -e "${GREEN}✓${NC} Dependencies installed (hash-verified via uv.lock)" else echo -e "${YELLOW}⚠${NC} Lockfile sync failed (see uv output above)." diff --git a/tests/test_install_sh_uv_lock_config.py b/tests/test_install_sh_uv_lock_config.py new file mode 100644 index 0000000000..59d5bba96d --- /dev/null +++ b/tests/test_install_sh_uv_lock_config.py @@ -0,0 +1,179 @@ +"""Regression coverage for project-aware locked syncs in Unix installers.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SCRIPTS = ( + REPO_ROOT / "scripts" / "install.sh", + REPO_ROOT / "setup-hermes.sh", +) +_HELPER_START = "run_locked_uv_sync() {\n" + + +def _locked_sync_helper(path: Path) -> str: + text = path.read_text(encoding="utf-8") + _, marker, rest = text.partition(_HELPER_START) + assert marker, f"{path.name} is missing run_locked_uv_sync()" + body, end, _ = rest.partition("\n}\n") + assert end, f"{path.name} has an unterminated run_locked_uv_sync()" + return marker + body + end + + +def _bash_path(path: Path) -> str: + if os.name != "nt": + return str(path) + drive = path.drive.rstrip(":").lower() + tail = path.as_posix().split(":", 1)[1].lstrip("/") + return f"/mnt/{drive}/{tail}" + + +def test_installers_keep_bootstrap_isolation_but_restore_project_config_for_lock() -> None: + install_text = INSTALL_SCRIPTS[0].read_text(encoding="utf-8") + setup_text = INSTALL_SCRIPTS[1].read_text(encoding="utf-8") + + assert "export UV_NO_CONFIG=1" in install_text + assert "export UV_NO_CONFIG=1" in setup_text + assert _locked_sync_helper(INSTALL_SCRIPTS[0]) == _locked_sync_helper( + INSTALL_SCRIPTS[1] + ) + + helper = _locked_sync_helper(INSTALL_SCRIPTS[0]) + assert "unset UV_NO_CONFIG UV_CONFIG_FILE" in helper + assert 'export XDG_CONFIG_HOME="$isolated_uv_config"' in helper + assert 'export XDG_CONFIG_DIRS="$isolated_uv_config"' in helper + assert "$UV_CMD sync --extra all --locked" in helper + assert 'run_locked_uv_sync "$INSTALL_DIR/venv"' in install_text + assert 'run_locked_uv_sync "$SCRIPT_DIR/venv"' in setup_text + + +def test_locked_sync_helper_sanitizes_only_its_subprocess(tmp_path: Path) -> None: + bash = shutil.which("bash") + if bash is None: + pytest.skip("bash is unavailable") + + record = tmp_path / "uv-args.txt" + fake_uv = tmp_path / "uv" + fake_uv.write_text( + """#!/bin/sh +test -z "${UV_NO_CONFIG+x}" || exit 10 +test -z "${UV_CONFIG_FILE+x}" || exit 11 +test -d "$XDG_CONFIG_HOME" || exit 12 +test "$XDG_CONFIG_HOME" = "$XDG_CONFIG_DIRS" || exit 13 +printf '%s\n' "$@" > "$RECORD" +""", + encoding="utf-8", + newline="\n", + ) + + harness = tmp_path / "harness.sh" + harness.write_text( + """#!/bin/bash +set -eu +UV_CMD="sh $1" +RECORD="$2" +export UV_CMD RECORD +export UV_NO_CONFIG=1 +export UV_CONFIG_FILE=/poison/uv.toml +export XDG_CONFIG_HOME=/poison/user +export XDG_CONFIG_DIRS=/poison/system +""" + + _locked_sync_helper(INSTALL_SCRIPTS[0]) + + """ +run_locked_uv_sync /tmp/hermes-venv +test "$UV_NO_CONFIG" = 1 +test "$UV_CONFIG_FILE" = /poison/uv.toml +test "$XDG_CONFIG_HOME" = /poison/user +test "$XDG_CONFIG_DIRS" = /poison/system +""", + encoding="utf-8", + newline="\n", + ) + + result = subprocess.run( + [bash, _bash_path(harness), _bash_path(fake_uv), _bash_path(record)], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert record.read_text(encoding="utf-8").splitlines() == [ + "sync", + "--extra", + "all", + "--locked", + ] + + +@pytest.mark.skipif(os.name == "nt", reason="Unix installer behavior") +def test_real_uv_accepts_lock_when_project_config_is_restored(tmp_path: Path) -> None: + uv = shutil.which("uv") + if uv is None: + pytest.skip("uv is unavailable") + + project = tmp_path / "project" + project.mkdir() + (project / "pyproject.toml").write_text( + """[project] +name = "installer-lock-regression" +version = "0.1.0" +requires-python = ">=3.11" + +[project.optional-dependencies] +all = [] + +[tool.uv] +package = false +exclude-newer = "14 days" +""", + encoding="utf-8", + ) + + isolated_config = tmp_path / "isolated-config" + isolated_config.mkdir() + clean_env = os.environ.copy() + clean_env.pop("UV_NO_CONFIG", None) + clean_env.pop("UV_CONFIG_FILE", None) + clean_env["XDG_CONFIG_HOME"] = str(isolated_config) + clean_env["XDG_CONFIG_DIRS"] = str(isolated_config) + clean_env["UV_OFFLINE"] = "1" + + locked = subprocess.run( + [uv, "lock"], + cwd=project, + env=clean_env, + capture_output=True, + text=True, + check=False, + ) + assert locked.returncode == 0, locked.stderr + + hidden_config_env = clean_env.copy() + hidden_config_env["UV_NO_CONFIG"] = "1" + rejected = subprocess.run( + [uv, "lock", "--check"], + cwd=project, + env=hidden_config_env, + capture_output=True, + text=True, + check=False, + ) + assert rejected.returncode != 0 + + accepted = subprocess.run( + [uv, "lock", "--check"], + cwd=project, + env=clean_env, + capture_output=True, + text=True, + check=False, + ) + assert accepted.returncode == 0, accepted.stderr