fix(setup): parse lock and mirror json by structure, not indentation
Two pre-Python readers anchored their awk patterns on the exact leading
whitespace of the machine-written json they parse:
- setup-hermes.sh read artifact-mirror.json with /^ "origin"/ (exact
two spaces), so any other one-member-per-line layout lost the mirror
fallback silently: the mirror url resolved empty and fetch_pinned
reported only the primary url it failed on.
- scripts/install.sh read packages.python.version from pm/lock.json
with exact 4-space and 6-space anchors; on any other layout the pin
resolved empty and the install fell back to the hardcoded "3.14".
setup-hermes.sh's own pin() already established the contract for the
same file ("follow object names and braces, not indentation"); both
sites now follow it. The mirror read is a flat key match; the python
pin uses the same brace-tracking reader as pin().
No other site in the tree has the pattern: setup-hermes.ps1 uses
ConvertFrom-Json, nix uses builtins.fromJSON, python readers use
json.loads, and remaining awk users parse command output, not config.
Tests:
- tests/pm/test_setup_lock_format.py now parametrizes the mirror json
indent (it was fixed at 2, leaving the mirror read unguarded) and
adds a mirror-fallback E2E: the primary url is a dead port (curl
exit 7, retriable), so the staged uv must come from the mirror,
with the mirror json written at 9-space indent. Red on the old
regex (mirror url resolves empty, exit 1), green with the fix.
- tests/test_install_sh_python_pin_indent.py (new) sources
install.sh --manifest and proves bootstrap_python resolves the
pinned version through a fake uv that records its calls, across
2/4/0/tab/blank-line lock layouts.
Verified via scripts/run_tests.sh: 16 passed, 0 failed on the fix; the
new mirror-fallback test fails against the old parsers (verified by
stashing the two script changes and re-running). Sibling install/setup
tests (test_install_sh_node_deps_workspaces, test_install_stage_frames,
test_install_sh_desktop_stage, pm/test_activate_scripts) all green.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
69
tests/test_install_sh_python_pin_indent.py
Normal file
69
tests/test_install_sh_python_pin_indent.py
Normal file
@@ -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}",
|
||||
]
|
||||
Reference in New Issue
Block a user