fix(pm): prepare platform trust before bootstrap downloads
Standalone Python cannot locate the system CA bundle on this NixOS host. Use the shell-staged uv to prepare the independent PM runtime before PM fetches managed Python. Declare and lock truststore in that runtime, then activate it before CLI and worker imports construct HTTPS clients. Make setup's awk pin reader follow object nesting rather than indentation. Use the same reader for tool versions and artifact fields. Verified cold activation with CA overrides removed, pinned Python and uv downloads, pm doctor, and a public worker HTTPS install. The targeted suite passed 56 tests with one Windows-only skip. The TLS regression fails when truststore is installed but entrypoint activation is removed. Bash syntax and Ruff checks passed. The full suite was not run. Two additional setup-toolchain tests fail on unchanged HEAD because their fixtures reach the real home before home isolation. CI bootstrap unification is not part of this change.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import truststore
|
||||
|
||||
# PM's import closure constructs HTTPS clients; install platform trust first.
|
||||
truststore.inject_into_ssl()
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from pm.cli import main
|
||||
|
||||
@@ -6,6 +6,7 @@ dependencies = [
|
||||
"packaging==26.0",
|
||||
"tomli-w==1.2.0",
|
||||
"ruamel.yaml==0.18.17",
|
||||
"truststore==0.10.4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -92,7 +92,7 @@ def _validate(python: Path, env: dict[str, str]) -> str:
|
||||
try:
|
||||
checked = subprocess.run(
|
||||
[str(python), "-I", "-B", "-c",
|
||||
"import packaging, tomli_w; from ruamel.yaml import YAML"],
|
||||
"import packaging, tomli_w, truststore; from ruamel.yaml import YAML"],
|
||||
env=env, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
@@ -160,8 +160,22 @@ def runtime_python(*, bootstrap: bool = True) -> Path:
|
||||
if not bootstrap:
|
||||
raise InstallError("pm-runtime", "not installed and lazy installs are disabled",
|
||||
"run `hermes pm install` to prepare the independent PM runtime")
|
||||
# This closure is deliberately stdlib-only: uv + Python, never Venv.
|
||||
uv, env = managed_uv(explicit=True)
|
||||
from pm.lock import Lockfile
|
||||
from pm.paths import lockfile_path, store_root
|
||||
from pm.registry import get_package
|
||||
from pm.store import current_target
|
||||
|
||||
# Setup has already verified/extracted uv, but there are no PM facts
|
||||
# yet. Use it to acquire PM's TLS support BEFORE downloading Python.
|
||||
package = get_package("uv")
|
||||
version = Lockfile(lockfile_path()).version("uv")
|
||||
target = current_target()
|
||||
staged = package.binary(store_root() / package.store_entry(version, target), target) if version else None
|
||||
if staged is not None and staged.is_file():
|
||||
uv, env = str(staged), {"UV_PYTHON": sys.executable}
|
||||
else:
|
||||
# Non-shell bootstrap callers (CI) already have a host interpreter.
|
||||
uv, env = managed_uv(explicit=True)
|
||||
if uv is None:
|
||||
raise InstallError("pm-runtime", "pinned uv and Python are unavailable")
|
||||
return prepare_runtime(Path(uv), Path(env["UV_PYTHON"]), install_state_dir(project) / "pm-runtime",
|
||||
|
||||
@@ -63,7 +63,7 @@ def stage_runtime(uv: Path, python: Path, destination: Path, *,
|
||||
if result.returncode:
|
||||
raise InstallError("pm-runtime", f"{command[1]} exited {result.returncode}")
|
||||
checked = subprocess.run(
|
||||
[str(executable), "-I", "-B", "-c", "import packaging, tomli_w; from ruamel.yaml import YAML"],
|
||||
[str(executable), "-I", "-B", "-c", "import packaging, tomli_w, truststore; from ruamel.yaml import YAML"],
|
||||
env=env, capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if checked.returncode:
|
||||
|
||||
11
pm/uv.lock
generated
11
pm/uv.lock
generated
@@ -10,6 +10,7 @@ dependencies = [
|
||||
{ name = "packaging" },
|
||||
{ name = "ruamel-yaml" },
|
||||
{ name = "tomli-w" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -17,6 +18,7 @@ requires-dist = [
|
||||
{ name = "packaging", specifier = "==26.0" },
|
||||
{ name = "ruamel-yaml", specifier = "==0.18.17" },
|
||||
{ name = "tomli-w", specifier = "==1.2.0" },
|
||||
{ name = "truststore", specifier = "==0.10.4" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -66,3 +68,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
@@ -44,6 +44,9 @@ def _read_controls(messages, pause):
|
||||
|
||||
|
||||
def main():
|
||||
import truststore
|
||||
|
||||
truststore.inject_into_ssl()
|
||||
# Capture the protocol FD before redirecting even native/subprocess stdout.
|
||||
wire = os.fdopen(os.dup(sys.stdout.fileno()), "w", encoding="utf-8", buffering=1)
|
||||
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
|
||||
|
||||
@@ -82,24 +82,24 @@ else
|
||||
fi
|
||||
target="$os-$arch"
|
||||
|
||||
# lock.json is machine-written (sorted keys, 2-space indent): read the uv
|
||||
# pin's version + this target's url/sha256 with awk — no python yet.
|
||||
pin() { # $1 = field (url | sha256)
|
||||
awk -F '"' -v target="$target" -v field="$1" '
|
||||
/^ "uv": \{/ { in_uv = 1 }
|
||||
in_uv && /^ }/ { exit }
|
||||
in_uv && /^ "/ { in_t = ($2 == target) }
|
||||
in_t && $2 == field { print $4; exit }' "$lock"
|
||||
# The machine-written lock has one member per line. Follow object names and
|
||||
# braces, not indentation, to read pins before Python is available.
|
||||
pin() { # $1 = field (url | sha256 | version), $2 = package (default: uv)
|
||||
awk -F '"' -v package="${2:-uv}" -v target="$target" -v field="$1" '
|
||||
/^[[:space:]]*("[^"]+"[[:space:]]*:[[:space:]]*)?\{[[:space:]]*$/ {
|
||||
path[++depth] = $2; next
|
||||
}
|
||||
/^[[:space:]]*}[[:space:]]*,?[[:space:]]*$/ {
|
||||
delete path[depth--]; next
|
||||
}
|
||||
path[2] == "packages" && path[3] == package && $2 == field &&
|
||||
((field == "version" && depth == 3) ||
|
||||
(depth == 5 && path[4] == "artifacts" && path[5] == target)) {
|
||||
print $4; exit
|
||||
}' "$lock"
|
||||
}
|
||||
uv_version="$(awk -F '"' '
|
||||
/^ "uv": \{/ { in_uv = 1 }
|
||||
in_uv && /^ }/ { exit }
|
||||
in_uv && $2 == "version" { print $4; exit }' "$lock")"
|
||||
py_version="$(awk -F '"' '
|
||||
/^ "python": \{/ { in_py = 1 }
|
||||
in_py && /^ }/ { exit }
|
||||
in_py && $2 == "version" { print $4; exit }' "$lock" \
|
||||
| cut -d+ -f1 | cut -d. -f1,2)"
|
||||
uv_version="$(pin version)"
|
||||
py_version="$(pin version python | cut -d+ -f1 | cut -d. -f1,2)"
|
||||
[ -n "$uv_version" ] || { echo -e "${RED}✗${NC} no uv pin in pm/lock.json" >&2; exit 1; }
|
||||
|
||||
store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}"
|
||||
|
||||
@@ -190,6 +190,9 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
def operations(name):
|
||||
return [line for line in calls.read_text().splitlines() if line.split()[0] == name]
|
||||
|
||||
def app_syncs():
|
||||
return [line for line in operations("sync") if "--frozen --all-packages" in line]
|
||||
|
||||
cold = activate()
|
||||
first = selection()
|
||||
probe = json.loads(cold.stdout)
|
||||
@@ -198,7 +201,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
assert probe["pythonpath"].split(os.pathsep)[0] == str(core)
|
||||
# Cold activation builds both PM's isolated runtime and the app environment.
|
||||
assert "Preparing the isolated PM runtime" in cold.stderr
|
||||
assert len(operations("venv")) == len(operations("sync")) == 2
|
||||
assert len(app_syncs()) == 1
|
||||
facts = json.loads((runtime / "facts.json").read_text())["packages"]
|
||||
assert facts["python"]["artifacts"] == [first_digest]
|
||||
assert facts["uv"]["artifacts"] == [uv_digest]
|
||||
@@ -209,8 +212,14 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
untouched = _snapshot(protected)
|
||||
activate()
|
||||
assert selection() == first
|
||||
assert len(operations("venv")) == len(operations("sync")) == 2
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 2
|
||||
# The first warm launch rebinds PM from bootstrap Python to its managed
|
||||
# interpreter. The application selection is unchanged; later launches reuse both.
|
||||
assert len(app_syncs()) == 1
|
||||
prepared = operations("sync")
|
||||
activate()
|
||||
assert selection() == first
|
||||
assert operations("sync") == prepared
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 3
|
||||
|
||||
second_digest = pin_python("second")
|
||||
activate()
|
||||
@@ -218,7 +227,7 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
assert second["stamp"] != first["stamp"]
|
||||
assert second["environment"] != first["environment"]
|
||||
assert Path(first["environment"]).is_dir()
|
||||
assert len(operations("venv")) == len(operations("sync")) == 3
|
||||
assert len(app_syncs()) == 2
|
||||
facts = json.loads((runtime / "facts.json").read_text())["packages"]
|
||||
assert facts["python"]["artifacts"] == [second_digest]
|
||||
assert (core / "uv.lock").read_bytes() == dependency_lock
|
||||
@@ -231,5 +240,5 @@ test "${PYTHONPATH-}" = "$prior_pythonpath" || exit 96
|
||||
assert "setup failed" in failed.stderr
|
||||
assert "CALLER_SURVIVED:" in failed.stdout
|
||||
assert selection() == second
|
||||
assert len(operations("sync")) == 4
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 4
|
||||
assert len(app_syncs()) == 3
|
||||
assert len([line for line in operations("python") if line.startswith("python install ")]) == 5
|
||||
|
||||
177
tests/pm/test_runtime_bootstrap_tls.py
Normal file
177
tests/pm/test_runtime_bootstrap_tls.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""The shell-staged uv can prepare TLS support before PM downloads Python."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.platforms("posix")
|
||||
def test_staged_uv_prepares_pm_before_any_tool_download(tmp_path):
|
||||
from pm.packages import Uv
|
||||
from pm.store import current_target
|
||||
|
||||
uv = shutil.which("uv")
|
||||
assert uv, "this bootstrap contract requires real uv"
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
stage = tmp_path / "source"
|
||||
for name in ("pm", "hermes_cli"):
|
||||
shutil.copytree(repo / name, stage / name, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
shutil.copy2(repo / "hermes_constants.py", stage / "hermes_constants.py")
|
||||
home = tmp_path / "home"
|
||||
store = home / "tools"
|
||||
target = current_target()
|
||||
# As in setup: uv has been verified/extracted, but PM has no installed facts.
|
||||
entry = store / Uv().store_entry("bootstrap-fixture", target)
|
||||
binary = Uv().binary(entry, target)
|
||||
assert binary is not None
|
||||
binary.parent.mkdir(parents=True)
|
||||
shutil.copy2(uv, binary)
|
||||
(stage / "pm" / "lock.json").write_text(json.dumps({"schema": 1, "packages": {
|
||||
name: {"version": "bootstrap-fixture", "artifacts": {target: {
|
||||
"url": f"https://must-not-fetch.invalid/{name}.tar.gz", "sha256": "a" * 64,
|
||||
}}} for name in ("uv", "python")
|
||||
}}))
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(("PYTHON", "UV_"))}
|
||||
env.update(HERMES_HOME=str(home), HERMES_RUNTIME_DIR=str(store))
|
||||
code = """
|
||||
import sys, subprocess
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from pm.runtime import runtime_command, runtime_environment
|
||||
result = subprocess.run(runtime_command(Path(sys.argv[2])), env=runtime_environment(),
|
||||
capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
print(result.stdout)
|
||||
"""
|
||||
probe = tmp_path / "probe.py"
|
||||
probe.write_text("import json, truststore; print(json.dumps({'tls': truststore.__file__}))")
|
||||
command = [sys.executable, "-I", "-S", "-c", code, str(stage), str(probe)]
|
||||
result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=180)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert Path(json.loads(result.stdout)["tls"]).is_relative_to(home)
|
||||
assert "Preparing the isolated PM runtime" in result.stderr
|
||||
assert "must-not-fetch.invalid" not in result.stderr
|
||||
warm = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, timeout=30)
|
||||
assert warm.returncode == 0, warm.stdout + warm.stderr
|
||||
assert "Preparing the isolated PM runtime" not in warm.stderr
|
||||
assert warm.stdout == result.stdout
|
||||
assert not (store / "facts.json").exists(), "preparing PM must not realize its tool closure"
|
||||
assert not list(store.glob("python-*"))
|
||||
assert not list(home.glob("installs/*/environments")), "no app environment during PM bootstrap"
|
||||
|
||||
|
||||
@pytest.mark.platforms("linux")
|
||||
def test_pm_cli_verifies_tls_with_platform_trust(tmp_path, monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from http.server import ThreadingHTTPServer
|
||||
from ipaddress import ip_address
|
||||
import io
|
||||
import ssl
|
||||
import tarfile
|
||||
import threading
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
from pm.runtime import prepare_runtime, runtime_environment
|
||||
from tests.pm._range_server import RangeHandler
|
||||
|
||||
home = tmp_path / "home"
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
|
||||
uv = shutil.which("uv")
|
||||
assert uv
|
||||
python = prepare_runtime(Path(uv), Path(sys.executable), tmp_path / "runtime")
|
||||
source = Path(__file__).resolve().parents[2]
|
||||
repo = tmp_path / "source"
|
||||
for name in ("pm", "hermes_cli"):
|
||||
shutil.copytree(source / name, repo / name, ignore=shutil.ignore_patterns("__pycache__"))
|
||||
shutil.copy2(source / "hermes_constants.py", repo / "hermes_constants.py")
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "PM test CA")])
|
||||
now = datetime.now(timezone.utc)
|
||||
cert = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject)
|
||||
.public_key(key.public_key()).serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(days=1)).not_valid_after(now + timedelta(days=1))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
|
||||
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ip_address("127.0.0.1"))]), critical=False)
|
||||
.sign(key, hashes.SHA256()))
|
||||
bundle, private = tmp_path / "ca.pem", tmp_path / "server.key"
|
||||
bundle.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
||||
private.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption()))
|
||||
payload = b"verified through platform trust"
|
||||
stream = io.BytesIO()
|
||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
||||
member = tarfile.TarInfo("payload.txt")
|
||||
member.size = len(payload)
|
||||
archive.addfile(member, io.BytesIO(payload))
|
||||
body = stream.getvalue()
|
||||
|
||||
class Handler(RangeHandler):
|
||||
payloads = {"/tool.tar.gz": body}
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(bundle, private)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
(repo / "pm" / "lock.json").write_text(json.dumps({"schema": 1, "packages": {
|
||||
"tls-test": {"version": "1", "artifacts": {"any": {
|
||||
"url": f"https://127.0.0.1:{server.server_port}/tool.tar.gz",
|
||||
"sha256": hashlib.sha256(body).hexdigest(),
|
||||
}}},
|
||||
}}))
|
||||
# Inject missing OpenSSL defaults, not a fake platform. Only truststore's
|
||||
# real Linux CA discovery can find the test CA; urllib alone must reject it.
|
||||
package = repo / "pm" / "tls_fixture.py"
|
||||
package.write_text("from pm import Package, register\n@register\nclass Tool(Package):\n name = 'tls-test'\n")
|
||||
driver = """
|
||||
import runpy, ssl, sys
|
||||
import truststore._openssl as platform_tls
|
||||
defaults = ssl.get_default_verify_paths()
|
||||
ssl.get_default_verify_paths = lambda: defaults._replace(cafile=None, capath=None)
|
||||
platform_tls._CA_FILE_CANDIDATES = [sys.argv.pop(1)]
|
||||
script = sys.argv.pop(1)
|
||||
sys.path.insert(0, sys.argv.pop(1))
|
||||
sys.argv[0] = script
|
||||
# Register only the fixture package; the real entrypoint owns TLS activation.
|
||||
module = runpy.run_path(script, run_name='tls_entrypoint_test')
|
||||
import pm.tls_fixture
|
||||
raise SystemExit(module['main']())
|
||||
"""
|
||||
env = runtime_environment()
|
||||
env.pop("SSL_CERT_FILE", None)
|
||||
env.pop("SSL_CERT_DIR", None)
|
||||
env["NO_PROXY"] = "127.0.0.1"
|
||||
try:
|
||||
command = [str(python), "-I", "-B", "-c", driver, str(tmp_path / "missing-ca"),
|
||||
str(repo / "pm" / "launch.py"), str(repo), "install", "tls-test"]
|
||||
rejected = subprocess.run(command, cwd=tmp_path, env=env,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert rejected.returncode == 1, rejected.stdout + rejected.stderr
|
||||
assert "CERTIFICATE_VERIFY_FAILED" in rejected.stdout + rejected.stderr
|
||||
assert not list((home / "tools").glob("tls-test-*/payload.txt"))
|
||||
command[5] = str(bundle)
|
||||
result = subprocess.run(command, cwd=tmp_path, env=env,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
installed = list((home / "tools").glob("tls-test-*/payload.txt"))
|
||||
assert len(installed) == 1, result.stdout + result.stderr
|
||||
assert installed[0].read_bytes() == payload
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
95
tests/pm/test_setup_lock_format.py
Normal file
95
tests/pm/test_setup_lock_format.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Exercise setup's pre-Python pin reader through real downloads and extraction.
|
||||
|
||||
Only the downloaded uv executable and the PM command at the handoff are fixtures;
|
||||
the copied setup script, curl, hashing, archive staging, and Python process are real.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from pm.store import current_target
|
||||
from tests.pm.test_pm_core import make_tar, served # noqa: F401 -- shared HTTP fixture
|
||||
|
||||
|
||||
pytestmark = pytest.mark.platforms("posix")
|
||||
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):
|
||||
bash = shutil.which("bash")
|
||||
assert bash, "the shell bootstrap contract requires Bash"
|
||||
core = tmp_path / "checkout with spaces"
|
||||
(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"
|
||||
calls = tmp_path / "uv-calls"
|
||||
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"printf '%s\\n' \"$*\" >> {shlex.quote(str(calls))}\n"
|
||||
'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'
|
||||
' *) exit 91 ;;\nesac\n'
|
||||
)
|
||||
docroot, base_url = served
|
||||
filename, digest = make_tar(docroot, "uv.tar.gz", {"uv-fixture/uv": uv_script})
|
||||
artifact = {"sha256": digest, "url": f"{base_url}/{filename}"}
|
||||
decoy = {"sha256": "0" * 64, "url": f"{base_url}/wrong-target.tar.gz"}
|
||||
target = current_target()
|
||||
data = {"packages": {
|
||||
"before": {"artifacts": {target: decoy}, "version": "wrong-before"},
|
||||
"python": {"artifacts": {target: decoy}, "version": f"{py_version}.7+fixture"},
|
||||
"uv": {"artifacts": {"before-target": decoy, target: artifact, "after-target": decoy},
|
||||
"version": uv_version},
|
||||
"after": {"artifacts": {target: 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")
|
||||
(core / "pm" / "artifact-mirror.json").write_text(
|
||||
json.dumps({"origin": base_url, "prefix": "mirror/"}, indent=2), 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"]
|
||||
assert calls.read_text().splitlines() == [
|
||||
"--version", f"python install --no-bin {py_version}",
|
||||
f"python find --managed-python {py_version}",
|
||||
]
|
||||
assert not (home / ".local").exists()
|
||||
assert not (core / ".env").exists()
|
||||
assert not (home / ".hermes" / "skills").exists()
|
||||
print(f"runtime-only bootstrap: indent={indent!r}, blank_lines={blank_lines}: exit {result.returncode}")
|
||||
print(result.stdout)
|
||||
@@ -5,8 +5,8 @@ import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import venv
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -18,9 +18,12 @@ from tests.pm._range_server import RangeHandler, dl_server, url # noqa: F401
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def isolated_python(tmp_path_factory):
|
||||
from pm.runtime_stage import stage_runtime
|
||||
|
||||
root = tmp_path_factory.mktemp("pm-python")
|
||||
venv.EnvBuilder(with_pip=False).create(root)
|
||||
python = root / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||||
uv = shutil.which("uv")
|
||||
assert uv, "the worker contract requires real uv"
|
||||
python = stage_runtime(Path(uv), Path(sys.executable), root)
|
||||
probe = subprocess.run(
|
||||
[str(python), "-I", "-c", "import importlib.util; assert importlib.util.find_spec('yaml') is None"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
|
||||
@@ -10,9 +10,9 @@ import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import shutil
|
||||
import tarfile
|
||||
import textwrap
|
||||
import venv
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -28,9 +28,12 @@ def restore_registry(monkeypatch):
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def worker_python(tmp_path_factory):
|
||||
from pm.runtime_stage import stage_runtime
|
||||
|
||||
environment = tmp_path_factory.mktemp("registry-worker-python")
|
||||
venv.EnvBuilder(with_pip=False).create(environment)
|
||||
return environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||||
uv = shutil.which("uv")
|
||||
assert uv, "the worker contract requires real uv"
|
||||
return stage_runtime(Path(uv), Path(sys.executable), environment)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["ensure", "stage_only"])
|
||||
|
||||
@@ -177,19 +177,26 @@ installation work: shell configuration, launchers, `.env`, and bundled skills.
|
||||
Run the setup script separately if you want that full installation workflow.
|
||||
|
||||
The bootstrap uses uv to install and locate Python, then waits for uv to exit.
|
||||
PM prepares its own small, locked Python environment before reading plugin
|
||||
configuration or resolving application dependencies. Its project is deliberately
|
||||
PM uses the staged uv to prepare its own small, locked Python environment
|
||||
before downloading its managed tools, reading plugin configuration, or resolving
|
||||
application dependencies. Its project is deliberately
|
||||
independent of the application workspace: a broken application dependency must
|
||||
not prevent its dependency manager from starting. Each uv subprocess exits before
|
||||
PM runs, so it cannot hold the uv executable that PM needs to replace.
|
||||
|
||||
PM's runtime contains `ruamel.yaml`, `packaging`, and `tomli-w`, not the application
|
||||
PM's runtime contains `ruamel.yaml`, `packaging`, `tomli-w`, and `truststore`, not the application
|
||||
dependency tree. CLI commands and application-requested installs and repairs run
|
||||
there; read-only path and installed-environment lookups remain local. PM never
|
||||
adds its dependencies to an already-running agent's imports. First-party YAML
|
||||
readers and writers use ruamel; third-party packages can still require PyYAML in
|
||||
the application environment. Failure receipts remain stdlib-only.
|
||||
|
||||
PM's CLI and worker activate `truststore` before importing their HTTPS clients.
|
||||
This uses the platform certificate store even when bootstrap Python's compiled-in
|
||||
OpenSSL paths do not locate it. No application dependencies or certificate-path
|
||||
override are required. After the first install, PM rebuilds its small environment
|
||||
against the managed Python on the next invocation; subsequent invocations reuse it.
|
||||
|
||||
When lazy installs are disabled, an existing PM runtime can still check whether
|
||||
the application environment is current. If PM itself is missing or outdated,
|
||||
the request fails without downloading tools or dependencies. Run an explicit
|
||||
|
||||
Reference in New Issue
Block a user