diff --git a/scripts/build-rein-runtime.py b/scripts/build-rein-runtime.py index 615f222..1147802 100644 --- a/scripts/build-rein-runtime.py +++ b/scripts/build-rein-runtime.py @@ -11,6 +11,7 @@ import hashlib import json import os import re +import shlex import subprocess from pathlib import Path @@ -43,6 +44,49 @@ def install_claude(output: Path, source: Path, sha256: str, version: str) -> dic "expected_version": version, "source": str(source)} +def relocate_entrypoints(output: Path) -> list[str]: + """Rewrite direct shebangs and uv/distlib long-path shell launchers. + + Long build paths (or spaces) produce a three-line shell/Python trampoline. + Its interpreter must also refer to the fixed in-sandbox runtime mount. + Recognize generated shapes, never arbitrary shell source or binary data. + """ + relocated = [] + python_names = [p.name for p in (output / "bin").iterdir() + if re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", p.name)] + interpreters = [str(output / "bin" / name) for name in python_names] + direct = {("#!" + name).encode() for name in interpreters} + trampolines = { + ("'''exec' " + quoted + ' "$0" "$@"').encode() + for name in interpreters + for quoted in (shlex.quote(name), "'" + name.replace("'", "'\\''") + "'") + } + for path in sorted((output / "bin").iterdir()): + if path.is_symlink() or not path.is_file(): + continue + with path.open("rb") as stream: + header = stream.read(8192) + lines = header.splitlines() + skip = 0 + if lines and lines[0] in direct: + skip = 1 + elif len(lines) >= 3 and lines[0] == b"#!/bin/sh": + if lines[1] in trampolines and lines[2] == b"' '''": + skip = 3 + elif lines[1].startswith(b"'''exec'") and str(output).encode() in lines[1]: + raise ValueError("unsupported build-host Python trampoline") + if skip: + body = path.read_bytes().split(b"\n", skip)[skip] + path.write_bytes(f"#!{RUNTIME_MOUNT}/bin/python3\n".encode() + body) + relocated.append(path.name) + for required in ("rein-aharness", "glas-harness", "sandboxer"): + path = output / "bin" / required + expected = f"#!{RUNTIME_MOUNT}/bin/python3".encode() + if path.exists() and path.read_bytes().split(b"\n", 1)[0] != expected: + raise ValueError(f"unrelocated governed entrypoint: {required}") + return relocated + + def verify_source_files(site: Path, mappings: list[tuple[Path, str, str]]) -> dict: """Refuse stale/missing/extra package contents even when versions match.""" expected = {} @@ -103,15 +147,7 @@ def build(output: Path, rein_source: Path, llm_source: Path, else: checked(["uv", "pip", "install", "--python", str(output / "bin/python3"), str(rein_source), str(llm_source)]) - # Console entrypoints must reference the in-sandbox mount, not the build host. - for path in (output / "bin").iterdir(): - if not path.is_file() or path.is_symlink(): - continue - with path.open("rb") as stream: - first_line = stream.readline(4096) - if first_line.startswith(f"#!{output}/bin/python".encode()): - body = path.read_bytes().partition(b"\n")[2] - path.write_bytes(f"#!{RUNTIME_MOUNT}/bin/python3\n".encode() + body) + entrypoints = relocate_entrypoints(output) metadata = json.loads(checked([ str(output / "bin/python3"), "-c", "import importlib.metadata,json,platform; " @@ -120,6 +156,7 @@ def build(output: Path, rein_source: Path, llm_source: Path, ])) if claude_binary is not None: metadata["claude"] = install_claude(output, claude_binary, claude_sha256, claude_version) + metadata["relocated_entrypoints"] = entrypoints metadata["source_revisions"] = revisions if owner_runtime: lock = json.loads((rein_source / "deploy/runtime-contract-lock.json").read_text()) diff --git a/tests/test_runtime_builder.py b/tests/test_runtime_builder.py index c4bb5a0..7f3e68b 100644 --- a/tests/test_runtime_builder.py +++ b/tests/test_runtime_builder.py @@ -96,3 +96,44 @@ def test_installed_package_contents_must_match_committed_source(tmp_path, change else: result = builder.verify_source_files(site, [(source, "fixture", "fixture")]) assert result["files_verified"] == 1 + + +@pytest.mark.parametrize("launcher", ["direct", "long-path", "spaces"]) +def test_console_entrypoint_runs_after_build_directory_disappears(tmp_path, monkeypatch, launcher): + import shutil + import subprocess + import sys + + name = {"long-path": "long-" + "x" * 150, "spaces": "build with spaces"}.get( + launcher, "build" + ) + output = tmp_path / name + (output / "bin").mkdir(parents=True) + shutil.copy2(sys.executable, output / "bin/python3") + interpreter = str(output / "bin/python3") + body = "print('entrypoint-ok')\n" + if launcher == "direct": + header = f"#!{interpreter}\n" + else: + header = "#!/bin/sh\n'''exec' '" + interpreter + "' \"$0\" \"$@\"\n' '''\n" + command = output / "bin/rein-aharness" + command.write_text(header + body) + command.chmod(0o755) + binary = output / "bin/native" + binary.write_bytes(b"\x7fELF\x00leave unchanged") + mount = tmp_path / "mounted" + monkeypatch.setattr(builder, "RUNTIME_MOUNT", str(mount)) + assert builder.relocate_entrypoints(output) == ["rein-aharness"] + shutil.move(output, mount) + assert not output.exists() + result = subprocess.run([str(mount / "bin/rein-aharness")], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "entrypoint-ok" + assert (mount / "bin/native").read_bytes() == b"\x7fELF\x00leave unchanged" + + +def test_unknown_governed_launcher_fails_closed(tmp_path): + (tmp_path / "bin").mkdir() + (tmp_path / "bin/rein-aharness").write_text("#!/bin/sh\nexec /unreviewed/python3\n") + with pytest.raises(ValueError, match="unrelocated governed entrypoint"): + builder.relocate_entrypoints(tmp_path) diff --git a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md index ce2d67b..612e327 100644 --- a/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md +++ b/workplans/SAND-WP-0015-bwrap-runtime-and-private-state.md @@ -9,7 +9,7 @@ flavor: implementation owner: codex topic_slug: bwrap-runtime-and-private-state created: "2026-09-05" -updated: "2026-09-09" +updated: "2026-09-27" state_hub_workstream_id: "d3f12387-fd23-58f0-b979-9c811507614d" --- @@ -282,3 +282,28 @@ The first standalone candidate exposed a stale cached local wheel despite curren Git metadata. Owner builds now refresh all local distributions and compare every installed package source/definition file with its tracked source; stale, missing or extra files refuse the build. Four content-conformance regressions cover that gap. + + +## 2026-09-27 installed launcher defect and guarded correction + +REINAH-WP-0003's single admitted attempt completed native provider/companion +apply, verify and exec, including scoped-token revocation. The claimed job +`6efa9436-6a91-47c0-94e5-b31b5a0e7e3a` failed before any provider request +reservation. The sandbox was destroyed and the source repository is unchanged. +The parent EUR 10 liability remains held for explicit reconciliation; no retry +or replacement authority is inferred. + +Installed artifact `b6e4e8a4` contains uv-generated three-line shell launchers +that still reference a vanished temporary build interpreter. The builder had +only rewritten direct Python shebangs. Fixed both generated forms and added +actual relocation/execution tests for direct, long and space-containing build +paths, plus refusal of unknown governed launchers. Builder tests: 12 passed; +focused lint passes. Rein's real installed bwrap probe reproduces both broken +entrypoints at exit 127 with zero provider forwards; its new bootstrap checks +refuse the malformed artifact before claiming. + +T04 remains `wait` and this plan stays `blocked`: replacement artifact build/ +admission and a separately authorized successful model/tool/commit proof remain. +The existing SAND-WP-0015 / REINAH-WP-0003 / SECRETS-WP-0009 records retain the +work. No new task or workplan. Detailed non-secret receipts are in rein-aharness +`docs/evidence/2026-09-27-metered-*.json`.