diff --git a/docs/owner-bootstrap.md b/docs/owner-bootstrap.md index 0cc101a..8477769 100644 --- a/docs/owner-bootstrap.md +++ b/docs/owner-bootstrap.md @@ -11,6 +11,18 @@ There is no key fetch, alternate provider, key file, OAuth/HOME or daemon fallba `--check` validates local pins without a key, queue access or workload dispatch; it can advance the ledger's clock watermark but never initializes/reset its schema. +**Rehearse before every paid attempt.** `--rehearse` (implies `--check`) also +runs the admitted runtime's `rein-aharness` and `glas-harness` launchers, and +`claude --version` when the build declares Claude, inside bwrap. The runtime is +read-only at the sandbox mount path; there is no network, a cleared +environment, no credentials, no provider route and no queue access. It writes +nothing beyond what `--check` writes. The receipt lists each exit code and +returns nonzero if any is not 0. The launcher check in `--check` reads only the +first line of each launcher; the rehearsal executes them. Run 6efa9436 +(2026-09-27) failed with exit 127 on exactly such a launcher. + + python -I -B -m rein_aharness.cli metered-once --owner-config /abs/owner.json --rehearse + Use the standalone candidate interpreter as `python -I -B -m rein_aharness.cli`. `-I` excludes editable PYTHONPATH/user-site fallbacks; `-B` prevents bytecode writes from changing the complete artifact digest. The source and namespace rein commands diff --git a/rein_aharness/cli.py b/rein_aharness/cli.py index 4af738d..08bf89f 100644 --- a/rein_aharness/cli.py +++ b/rein_aharness/cli.py @@ -559,6 +559,8 @@ def main(argv: list[str] | None = None) -> int: ) metered.add_argument("--owner-config", required=True, type=Path) metered.add_argument("--check", action="store_true", help="Validate local pins without claiming") + metered.add_argument("--rehearse", action="store_true", + help="With --check: also run the runtime's launchers in bwrap, offline") metered.add_argument("--no-hub", action="store_true") claim_loop = sub.add_parser( @@ -631,7 +633,8 @@ def main(argv: list[str] | None = None) -> int: if args.command == "metered-once": from rein_aharness.owner_bootstrap import run_once - return run_once(args.owner_config, check_only=args.check, report_to_hub=not args.no_hub) + return run_once(args.owner_config, check_only=args.check, report_to_hub=not args.no_hub, + rehearse=args.rehearse) if args.command == "claim-loop": return _cmd_claim_loop(args) diff --git a/rein_aharness/owner_bootstrap.py b/rein_aharness/owner_bootstrap.py index 824538c..f96e4d3 100644 --- a/rein_aharness/owner_bootstrap.py +++ b/rein_aharness/owner_bootstrap.py @@ -99,10 +99,12 @@ def prepare(path: Path, config: OpsRunConfig) -> tuple[MessagesPolicy, Path, str raise BootstrapRefused("owner bootstrap configuration refused") from None -def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True) -> int: +def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True, + rehearse: bool = False) -> int: # Only this explicit exec-env child entry point consumes the conventional key. # Remove it before any subprocess or queue client can inherit it. No OAuth, # operator HOME, alternate provider URL or credential-file fallback is used. + check_only = check_only or rehearse provider_key = None if check_only else os.environ.get("ANTHROPIC_API_KEY") alternate_auth = None if check_only else os.environ.get("ANTHROPIC_AUTH_TOKEN") for name in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"): @@ -114,9 +116,16 @@ def run_once(path: Path, *, check_only: bool = False, report_to_hub: bool = True config = OpsRunConfig.from_env() policy, runtime_path, runtime_sha256 = prepare(path, config) if check_only: - print(json.dumps({"ok": True, "check_only": True, "dispatch_enabled": False, - "policy_sha256": policy.sha256, "runtime_sha256": runtime_sha256})) - return 0 + ok = True + receipt = {"check_only": True, "dispatch_enabled": False, + "policy_sha256": policy.sha256, "runtime_sha256": runtime_sha256} + if rehearse: + from rein_aharness.owner_rehearsal import rehearse as rehearse_runtime + steps = rehearse_runtime(runtime_path) + ok = all(step.ok for step in steps) + receipt["rehearsal"] = {step.name: step.exit_code for step in steps} + print(json.dumps({"ok": ok, **receipt})) + return 0 if ok else 1 if (alternate_auth or not isinstance(provider_key, str) or not provider_key or len(provider_key) > 8192 or any(ord(c) < 33 or ord(c) > 126 for c in provider_key)): diff --git a/rein_aharness/owner_rehearsal.py b/rein_aharness/owner_rehearsal.py new file mode 100644 index 0000000..f753657 --- /dev/null +++ b/rein_aharness/owner_rehearsal.py @@ -0,0 +1,91 @@ +"""Free host rehearsal of an admitted owner runtime (REINAH-WP-0008-T01). + +The owner check reads only the first line of each launcher. The 2026-09-27 +run 6efa9436 passed that and still failed with exit 127, because a launcher +re-executed a deleted build-host interpreter. This rehearsal runs the +launchers the way the sandbox does: inside bwrap, with the runtime read-only +at the mount path, no network, a cleared environment and nothing else +writable. It uses no credentials, provider route, queue or ledger. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + +RUNTIME_MOUNT = "/opt/sandboxer/runtime" +# Same read-only system binds as sand-boxer's bwrap extension. +SYSTEM_RO_BINDS = ("/usr", "/bin", "/lib", "/lib64", "/etc/resolv.conf", "/etc/ssl/certs") +PYTHON_LAUNCHERS = ("rein-aharness", "glas-harness") +TIMEOUT_SECONDS = 60 + +Runner = Callable[[Sequence[str], int], int] + + +@dataclass(frozen=True) +class Step: + name: str + exit_code: int + + @property + def ok(self) -> bool: + return self.exit_code == 0 + + +def bwrap_argv(runtime: Path, command: Sequence[str], *, bwrap_bin: str = "bwrap") -> list[str]: + argv = [ + bwrap_bin, + "--unshare-user", "--unshare-pid", "--unshare-ipc", + "--unshare-uts", "--unshare-cgroup", "--unshare-net", + "--tmpfs", "/", "--proc", "/proc", "--dev", "/dev", "--clearenv", + ] + for path in SYSTEM_RO_BINDS: + if Path(path).exists(): + argv += ["--ro-bind", path, path] + argv += [ + "--dir", "/opt", "--dir", "/opt/sandboxer", + "--ro-bind", str(runtime), RUNTIME_MOUNT, + "--dir", "/tmp", + "--setenv", "HOME", "/tmp", + "--setenv", "PATH", f"{RUNTIME_MOUNT}/bin:/usr/bin:/bin", + "--chdir", "/tmp", + ] + return argv + list(command) + + +def _run(argv: Sequence[str], timeout: int) -> int: + try: + return subprocess.run( + list(argv), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=timeout, + ).returncode + except subprocess.TimeoutExpired: + return 124 + except OSError: + return 127 + + +def _expects_claude(runtime: Path) -> bool: + try: + info = json.loads((runtime / "build-info.json").read_text()) + except (OSError, ValueError): + return False + return isinstance(info, dict) and isinstance(info.get("claude"), dict) + + +def rehearse(runtime: Path, *, run: Runner = _run, bwrap_bin: str | None = None) -> list[Step]: + """Run each launcher once inside bwrap; return one step per launcher.""" + bwrap = bwrap_bin or shutil.which("bwrap") + if bwrap is None: + return [Step("bwrap", 127)] + commands = [(name, [f"{RUNTIME_MOUNT}/bin/{name}", "--help"]) for name in PYTHON_LAUNCHERS] + if _expects_claude(runtime): + commands.append(("claude", [f"{RUNTIME_MOUNT}/bin/claude", "--version"])) + return [ + Step(name, run(bwrap_argv(runtime, command, bwrap_bin=bwrap), TIMEOUT_SECONDS)) + for name, command in commands + ] diff --git a/tests/test_owner_bootstrap.py b/tests/test_owner_bootstrap.py index 53d47fe..d211cff 100644 --- a/tests/test_owner_bootstrap.py +++ b/tests/test_owner_bootstrap.py @@ -149,3 +149,26 @@ def test_unexpected_exception_is_bounded(prepared, monkeypatch, capsys): monkeypatch.setattr("rein_aharness.claim_loop.process_one", MagicMock(side_effect=RuntimeError("fixture-key"))) assert run_once(path) == 1 assert json.loads(capsys.readouterr().out) == {"ok": False, "code": "owner_cycle_failed"} + + +@pytest.mark.parametrize("exit_codes,code", [((0, 0), 0), ((0, 127), 1)]) +def test_rehearsal_reports_launcher_exits_and_never_claims_or_reads_key( + prepared, monkeypatch, capsys, exit_codes, code): + from rein_aharness.owner_rehearsal import Step + path, _, _, runtime = prepared + monkeypatch.setenv("ANTHROPIC_API_KEY", "fixture-owner-key") + seen = [] + def fake(runtime_path): + seen.append(runtime_path) + return [Step(n, c) for n, c in zip(("rein-aharness", "glas-harness"), exit_codes)] + monkeypatch.setattr("rein_aharness.owner_rehearsal.rehearse", fake) + claimant = MagicMock() + monkeypatch.setattr("rein_aharness.claim_loop.process_one", claimant) + assert run_once(path, rehearse=True) == code + claimant.assert_not_called() + assert seen == [runtime] + out = json.loads(capsys.readouterr().out) + assert out["ok"] is (code == 0) and out["dispatch_enabled"] is False + assert out["rehearsal"] == {"rein-aharness": exit_codes[0], "glas-harness": exit_codes[1]} + assert "fixture-owner-key" not in json.dumps(out) + assert "ANTHROPIC_API_KEY" not in os.environ diff --git a/tests/test_owner_rehearsal.py b/tests/test_owner_rehearsal.py new file mode 100644 index 0000000..c2bbd69 --- /dev/null +++ b/tests/test_owner_rehearsal.py @@ -0,0 +1,78 @@ +"""Real bwrap rehearsal of owner runtimes (REINAH-WP-0008-T01).""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from rein_aharness.owner_rehearsal import RUNTIME_MOUNT, bwrap_argv, rehearse + + +def _bwrap_usable() -> bool: + bwrap = shutil.which("bwrap") + if bwrap is None: + return False + probe = subprocess.run(bwrap_argv(Path("/usr"), ["/usr/bin/true"], bwrap_bin=bwrap), + capture_output=True, timeout=30) + return probe.returncode == 0 + + +real_bwrap = pytest.mark.skipif(not _bwrap_usable(), reason="bwrap user namespaces unavailable") + +GOOD = f"#!{RUNTIME_MOUNT}/bin/python3\nimport sys\nsys.exit(0)\n" +# uv's long-path trampoline that failed run 6efa9436: a shell script that +# re-executes a build-host interpreter which no longer exists. +BROKEN = "#!/bin/sh\n'''exec' '/old-build/bin/python3' \"$0\" \"$@\"\n' '''\nimport sys\n" + + +def _runtime(tmp_path: Path, glas: str = GOOD) -> Path: + runtime = tmp_path / "runtime" + (runtime / "bin").mkdir(parents=True) + (runtime / "bin/python3").symlink_to("/usr/bin/python3") + for name, body in (("rein-aharness", GOOD), ("glas-harness", glas)): + launcher = runtime / "bin" / name + launcher.write_text(body) + launcher.chmod(0o755) + return runtime + + +def test_argv_is_offline_read_only_and_credential_free(tmp_path): + argv = bwrap_argv(tmp_path, [f"{RUNTIME_MOUNT}/bin/rein-aharness", "--help"]) + assert "--unshare-net" in argv and "--clearenv" in argv + assert "--bind" not in argv + i = argv.index(str(tmp_path)) + assert argv[i - 1] == "--ro-bind" and argv[i + 1] == RUNTIME_MOUNT + assert not any("ANTHROPIC" in part or "/run/sandboxer" in part for part in argv) + + +def test_missing_bwrap_fails_closed(tmp_path, monkeypatch): + monkeypatch.setattr("rein_aharness.owner_rehearsal.shutil.which", lambda name: None) + steps = rehearse(_runtime(tmp_path), run=lambda argv, timeout: 0) + assert [(s.name, s.ok) for s in steps] == [("bwrap", False)] + + +def test_claude_is_rehearsed_only_when_the_build_declares_it(tmp_path): + runtime = _runtime(tmp_path) + calls = [] + run = lambda argv, timeout: calls.append(argv[-2:]) or 0 + assert [s.name for s in rehearse(runtime, run=run, bwrap_bin="bwrap")] == [ + "rein-aharness", "glas-harness"] + (runtime / "build-info.json").write_text(json.dumps({"claude": {"expected_version": "x"}})) + assert [s.name for s in rehearse(runtime, run=run, bwrap_bin="bwrap")][-1] == "claude" + assert calls[-1] == [f"{RUNTIME_MOUNT}/bin/claude", "--version"] + + +@real_bwrap +def test_relocated_runtime_passes(tmp_path): + steps = rehearse(_runtime(tmp_path)) + assert [(s.name, s.exit_code) for s in steps] == [("rein-aharness", 0), ("glas-harness", 0)] + + +@real_bwrap +def test_launcher_with_deleted_build_interpreter_fails_like_run_6efa9436(tmp_path): + steps = {s.name: s.exit_code for s in rehearse(_runtime(tmp_path, glas=BROKEN))} + assert steps == {"rein-aharness": 0, "glas-harness": 127} diff --git a/workplans/REINAH-WP-0008-simpler-metered-retry.md b/workplans/REINAH-WP-0008-simpler-metered-retry.md index 3e80e2b..633f080 100644 --- a/workplans/REINAH-WP-0008-simpler-metered-retry.md +++ b/workplans/REINAH-WP-0008-simpler-metered-retry.md @@ -46,17 +46,17 @@ once, run. ```task id: REINAH-WP-0008-T01 -status: todo +status: done priority: high state_hub_task_id: "c6536c8d-7862-5de6-ad2b-401077b60c04" ``` -Add `rein-aharness owner check --rehearse `. It runs everything -`metered-once --check` does. It then executes the admitted runtime's -`rein-aharness` and `glas-harness` launchers inside bwrap, from the mount -path, with `--unshare-all`, no credentials, no provider route and no ledger -write. It also checks that the sandbox profile resolves. It prints a single -pass or fail line with the runtime digest. +Add a free rehearsal to the owner check. It runs everything +`metered-once --check` does, then executes the admitted runtime's +`rein-aharness` and `glas-harness` launchers inside bwrap from the mount +path, with no network, credentials, provider route or ledger write. The +sandbox profile is already resolved by `--check`. It prints one receipt with +the runtime digest and each launcher's exit code. Today's launcher check reads only the first line of each launcher. This rehearsal runs them, the same way the failing run did. @@ -67,6 +67,27 @@ relocated runtime. It must make no network calls and write nothing. The owner procedure in `docs/owner-bootstrap.md` makes the rehearsal a required step before any paid attempt. +### Implemented — 2026-10-13 + +`rein-aharness metered-once --owner-config --rehearse` (the flag +implies `--check`, so it never reads a provider key or claims). The code is in +`rein_aharness/owner_rehearsal.py`. It uses sand-boxer's isolation flags and +read-only system binds, with the runtime read-only at `/opt/sandboxer/runtime`, +`--unshare-net` and `--clearenv`. It runs each Python launcher with `--help`, +plus `claude --version` when `build-info.json` declares Claude. A missing +bwrap fails closed. + +Tests (`tests/test_owner_rehearsal.py`, `tests/test_owner_bootstrap.py`): + +- With real bwrap, a relocated runtime passes. The uv trampoline that + re-executes a deleted build interpreter fails with 127, as run `6efa9436` + did. +- The rehearsal never claims and never reads the key. +- The command line has no writable bind and no credential. + +The corrected runtime `ee3d0a53…` rehearses clean: rein-aharness 0, +glas-harness 0, claude 0. + ## B. Release holds that provably cost nothing ```task