diff --git a/scripts/install.sh b/scripts/install.sh index 618f01f4d3..c78bead0ff 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -329,9 +329,13 @@ stage_venv() { bootstrap_python() { ensure_uv local _py - _py="$(awk '/^ "python": \{/ { in_py = 1 } - in_py && /^ "version":/ { gsub(/.*: "|"$|",$/, ""); print; exit }' \ - "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)" + # Read packages.python.version by following object names and braces, not + # indentation — same pre-Python reader contract as setup-hermes.sh's pin(). + _py="$(awk -F '"' ' + /^[[:space:]]*("[^"]+"[[:space:]]*:[[:space:]]*)?\{/ { path[++depth] = $2; next } + /^[[:space:]]*\}[[:space:]]*,?[[:space:]]*$/ { delete path[depth--]; next } + path[2] == "packages" && path[3] == "python" && $2 == "version" && depth == 3 { print $4; exit } + ' "$INSTALL_DIR/pm/lock.json" | cut -d+ -f1 | cut -d. -f1,2)" [ -n "$_py" ] || _py="3.14" "$UV_CMD" python install --no-bin "$_py" || fail "bootstrap Python installation failed" boot_py="$("$UV_CMD" python find --managed-python "$_py")" || fail "bootstrap Python lookup failed" diff --git a/setup-hermes.sh b/setup-hermes.sh index f62ac829c0..a2cab35fbe 100755 --- a/setup-hermes.sh +++ b/setup-hermes.sh @@ -51,9 +51,10 @@ echo -e "${CYAN}→${NC} Checking for uv..." lock="$SCRIPT_DIR/pm/lock.json" [ -f "$lock" ] || { echo -e "${RED}✗${NC} pm/lock.json not found" >&2; exit 1; } -# Read the shared mirror location before Python is available. -mirror_origin="$(awk -F '"' '/^ "origin"/ { print $4; exit }' "$SCRIPT_DIR/pm/artifact-mirror.json")" -mirror_prefix="$(awk -F '"' '/^ "prefix"/ { print $4; exit }' "$SCRIPT_DIR/pm/artifact-mirror.json")" +# Read the shared mirror location before Python is available. Match object +# keys, not indentation — same rule as pin() below. +mirror_origin="$(awk -F '"' '$2 == "origin" { print $4; exit }' "$SCRIPT_DIR/pm/artifact-mirror.json")" +mirror_prefix="$(awk -F '"' '$2 == "prefix" { print $4; exit }' "$SCRIPT_DIR/pm/artifact-mirror.json")" mirror_url_for() { # $1 = lowercase sha256 [ -n "$mirror_origin" ] && [ -n "$mirror_prefix" ] || return 1 printf '%s/%s%s' "$mirror_origin" "$mirror_prefix" "$1" diff --git a/tests/pm/test_setup_lock_format.py b/tests/pm/test_setup_lock_format.py index d2c4abde0f..982f4f454e 100644 --- a/tests/pm/test_setup_lock_format.py +++ b/tests/pm/test_setup_lock_format.py @@ -25,7 +25,8 @@ REPO = Path(__file__).resolve().parents[2] @pytest.mark.parametrize("indent,blank_lines", [(2, False), (4, False), (0, False), ("\t", False), (4, True)], ids=["two-spaces", "four-spaces", "no-indent", "tabs", "blank-lines"]) -def test_setup_reads_pins_independent_of_indentation(tmp_path, served, indent, blank_lines): +@pytest.mark.parametrize("mirror_indent", [2, 7], ids=["mirror-two-spaces", "mirror-seven-spaces"]) +def test_setup_reads_pins_independent_of_indentation(tmp_path, served, indent, blank_lines, mirror_indent): bash = shutil.which("bash") assert bash, "the shell bootstrap contract requires Bash" core = tmp_path / "checkout with spaces" @@ -72,7 +73,7 @@ def test_setup_reads_pins_independent_of_indentation(tmp_path, served, indent, b content = content.replace("\n", "\n \t\n") (core / "pm" / "lock.json").write_text(content + "\n", encoding="utf-8") (core / "pm" / "artifact-mirror.json").write_text( - json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=2), encoding="utf-8", + json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=mirror_indent), encoding="utf-8", ) env = {"PATH": os.environ["PATH"], "HOME": str(home), "HERMES_HOME": str(home / ".hermes"), "HERMES_RUNTIME_DIR": str(runtime), @@ -93,3 +94,71 @@ def test_setup_reads_pins_independent_of_indentation(tmp_path, served, indent, b assert not (home / ".hermes" / "skills").exists() print(f"runtime-only bootstrap: indent={indent!r}, blank_lines={blank_lines}: exit {result.returncode}") print(result.stdout) + + +def test_setup_mirror_fallback_reads_any_layout(tmp_path, served): + """The mirror fallback consumes artifact-mirror.json read pre-Python. + + The primary URL is a dead port (curl exit 7, in the retriable set), so the + staged artifact must come from the mirror; the mirror json is written as a + compact single line to prove the reader follows keys, not indentation. + """ + bash = shutil.which("bash") + assert bash, "the shell bootstrap contract requires Bash" + core = tmp_path / "checkout" + (core / "pm").mkdir(parents=True) + shutil.copy2(REPO / "setup-hermes.sh", core / "setup-hermes.sh") + home = tmp_path / "home" + home.mkdir() + runtime = tmp_path / "runtime" + interpreter = str(Path(sys._base_executable).resolve()) + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + uv_version = "fixture-pin" + receipt = core / "handoff.json" + (core / "pm" / "__init__.py").touch() + (core / "pm" / "cli.py").write_text( + "import json, pathlib, sys\n" + "assert sys.argv[1:] == ['install'], sys.argv\n" + f"pathlib.Path({str(receipt)!r}).write_text(json.dumps(sys.argv[1:]))\n", + encoding="utf-8", + ) + uv_script = ( + f"#!{bash}\nset -eu\n" + f'case "$*" in\n' + f' --version) printf \'%s\\n\' "uv {uv_version}" ;;\n' + f' "python install --no-bin {py_version}") ;;\n' + f' "python find --managed-python {py_version}") printf \'%s\\n\' {shlex.quote(interpreter)} ;;\n' + f' *) exit 91 ;;\n' + f'esac\n' + ) + docroot, base_url = served + mirror_dir = docroot / "mirror" + mirror_dir.mkdir() + filename, digest = make_tar(docroot, "uv.tar.gz", {"uv-fixture/uv": uv_script}) + shutil.copy2(docroot / filename, mirror_dir / digest) + # Dead port: curl cannot connect (exit 7), which fetch_pinned treats as + # retriable, forcing the mirror branch. + dead = "http://127.0.0.1:1/uv.tar.gz" + artifact = {"sha256": digest, "url": dead} + target = current_target() + data = {"packages": { + "python": {"artifacts": {target: {"sha256": "0" * 64, "url": dead}}, + "version": f"{py_version}.7+fixture"}, + "uv": {"artifacts": {target: artifact}, "version": uv_version}, + }} + (core / "pm" / "lock.json").write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + # Hostile indentation (one member per line, 9 spaces): the reader must + # follow keys, not the 2-space layout the real file ships with. + (core / "pm" / "artifact-mirror.json").write_text( + json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=9) + "\n", encoding="utf-8", + ) + env = {"PATH": os.environ["PATH"], "HOME": str(home), + "HERMES_HOME": str(home / ".hermes"), "HERMES_RUNTIME_DIR": str(runtime), + "PYTHONNOUSERSITE": "1"} + result = subprocess.run( + [bash, str(core / "setup-hermes.sh"), "--runtime-only"], cwd=tmp_path, + env=env, capture_output=True, text=True, timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert (runtime / f"uv-{uv_version}-{target}" / "uv").read_text() == uv_script + assert json.loads(receipt.read_text()) == ["install"] diff --git a/tests/test_install_sh_python_pin_indent.py b/tests/test_install_sh_python_pin_indent.py new file mode 100644 index 0000000000..d7b93c3efc --- /dev/null +++ b/tests/test_install_sh_python_pin_indent.py @@ -0,0 +1,69 @@ +"""install.sh reads the python pin from pm/lock.json by structure, not layout. + +bootstrap_python's pre-Python awk reader must follow object names and braces +(the same contract setup-hermes.sh's pin() established), so any indentation the +lock writer produces resolves the same pinned version. +""" +import json +import os +from pathlib import Path +import shlex +import shutil +import subprocess +import sys + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +pytestmark = pytest.mark.platforms("posix") + + +@pytest.mark.parametrize("indent,blank_lines", [(2, False), (4, False), (0, False), ("\t", False), (4, True)], + ids=["two-spaces", "four-spaces", "no-indent", "tabs", "blank-lines"]) +def test_bootstrap_python_reads_pin_independent_of_indentation(tmp_path, indent, blank_lines): + bash = shutil.which("bash") + assert bash, "the shell bootstrap contract requires Bash" + core = tmp_path / "checkout" + (core / "pm").mkdir(parents=True) + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + interpreter = str(Path(sys._base_executable).resolve()) + calls = tmp_path / "uv-calls" + uv = tmp_path / "uv" + uv.write_text( + f"#!{bash}\nset -eu\n" + f"printf '%s\\n' \"$*\" >> {shlex.quote(str(calls))}\n" + 'case "$*" in\n' + f' "python install --no-bin {py_version}") ;;\n' + f' "python find --managed-python {py_version}") printf \'%s\\n\' {shlex.quote(interpreter)} ;;\n' + ' *) exit 91 ;;\n' + 'esac\n', + encoding="utf-8", + ) + uv.chmod(0o755) + decoy = {"sha256": "0" * 64, "url": "http://127.0.0.1:1/wrong.tar.gz"} + data = {"packages": { + "before": {"artifacts": {"any": decoy}, "version": "wrong-before"}, + "python": {"artifacts": {"any": decoy}, "version": f"{py_version}.7+fixture"}, + "after": {"artifacts": {"any": decoy}, "version": "wrong-after"}, + }} + content = json.dumps(data, indent=indent) + if blank_lines: + content = content.replace("\n", "\n \t\n") + (core / "pm" / "lock.json").write_text(content + "\n", encoding="utf-8") + script = ( + f'source "{(ROOT / "scripts/install.sh").as_posix()}" --manifest\n' + f'ensure_uv() {{ UV_CMD="{uv.as_posix()}"; }}\n' + f'INSTALL_DIR="{core.as_posix()}"\n' + "bootstrap_python\n" + ) + env = dict(os.environ, HOME=str(tmp_path), HERMES_HOME=str(tmp_path / ".hermes")) + result = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stdout + result.stderr + # A layout-sensitive reader resolves no version, falls back to "3.14", and + # the fake uv exits 91 on the unexpected argument — so the recorded calls + # pinning THIS interpreter's version are the contract. + assert calls.read_text().splitlines() == [ + f"python install --no-bin {py_version}", + f"python find --managed-python {py_version}", + ]