diff --git a/CHANGELOG.md b/CHANGELOG.md index 46473f1..6589ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project are documented here. - Recorded a same-fixture bounded-search development pair with equal verification and lower observed provider tokens/time, while keeping it separate from held-out evidence. - Recorded a separate cache-boundary repair pair with equal verification, fewer observed provider tokens, and longer elapsed time; expanded the research review with prompt-cache economics. - Recorded a historical CRLF loader pair whose apparent ON success failed a broader behavior matrix; added regression tests for source-line-ending invariance and bootstrap pin integrity. +- Added a loader-protocol guard: `sol_bootstrap.py` bytes are frozen for the released `runtime-v1` directory, because changed bytes there would make every installed task fail with `runtime collision or corruption`. A companion test shows that the same change under a new runtime directory loads. - Probed macOS filesystem isolation for historical-task agents and documented the remaining protection work before a confirmatory campaign. - Added a cost-frontier research protocol based on provider-billed coding-agent studies and independent Astra review. - Extended the opt-in artifact search with bounded line-range retrieval and explicit reporting when long matched lines are omitted. diff --git a/plugins/sol-codex/scripts/test_hook_commands.py b/plugins/sol-codex/scripts/test_hook_commands.py index 079d04a..f5e9f55 100644 --- a/plugins/sol-codex/scripts/test_hook_commands.py +++ b/plugins/sol-codex/scripts/test_hook_commands.py @@ -2,8 +2,11 @@ from __future__ import annotations +import base64 +import hashlib import json import os +import re import shutil import subprocess import sys @@ -17,6 +20,13 @@ GENERATOR = PLUGIN_ROOT.parents[1] / "scripts" / "generate_hook_commands.py" # The generator ships with the repository, not with the installed plugin. REQUIRES_GENERATOR = unittest.skipUnless(GENERATOR.is_file(), "hook command generator is not packaged") +# Every release shares PLUGIN_DATA//bootstrap.py. Loader +# bytes that change under a released directory make publish() raise in every +# task, so all hooks degrade. A loader change needs a new runtime directory +# and regenerated hook commands. +RELEASED_BOOTSTRAP_DIGESTS = { + "runtime-v1": "84e04d0b3dcffad4aa060ec30194f5b8168c8c381b1210e0f5192e6f583d9e1f", +} class HookCommandTests(unittest.TestCase): @@ -195,6 +205,61 @@ def test_ambiguous_unbound_root_is_rejected(self) -> None: self.assertEqual(result.stdout, "") self.assertIn("runtime unavailable or ambiguous", result.stderr) + def launcher_source(self) -> str: + encoded = re.search(r"b64decode\(\W*([A-Za-z0-9+/=]{40,})", self.commands[0]) + self.assertIsNotNone(encoded) + return base64.b64decode(encoded.group(1)).decode("utf-8") + + def test_released_runtime_directory_keeps_its_bootstrap_bytes(self) -> None: + source = (PLUGIN_ROOT / "scripts" / "sol_bootstrap.py").read_bytes().replace(b"\r\n", b"\n") + digest = hashlib.sha256(source).hexdigest() + directories = set(re.findall(r'"(runtime-v[0-9]+)"', source.decode("utf-8"))) + self.assertEqual(len(directories), 1, directories) + directory = directories.pop() + launcher = self.launcher_source() + self.assertIn(f'"{directory}"', launcher) + self.assertIn(digest, launcher) + if directory in RELEASED_BOOTSTRAP_DIGESTS: + self.assertEqual( + digest, RELEASED_BOOTSTRAP_DIGESTS[directory], + f"sol_bootstrap.py changed under released {directory}; installed tasks would fail " + "with 'runtime collision or corruption'. Move the loader to a new runtime directory " + "and regenerate the hook commands.", + ) + + @unittest.skipIf(os.name == "nt", "builds a POSIX launcher command") + def test_changed_loader_needs_a_new_runtime_directory(self) -> None: + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + data = base / "data" + released = base / "released" + self.fixture(released, "released") + self.assertEqual(self.invoke(released, data).returncode, 0) + original = (released / "scripts" / "sol_bootstrap.py").read_bytes().replace(b"\r\n", b"\n") + launcher = self.launcher_source() + for runtime, expected in (("runtime-v1", None), ("runtime-v2", "upgraded")): + with self.subTest(runtime=runtime): + root = base / runtime + self.fixture(root, "upgraded") + changed = original.replace(b'"runtime-v1"', f'"{runtime}"'.encode()) + b"# changed loader\n" + (root / "scripts" / "sol_bootstrap.py").write_bytes(changed) + code = launcher.replace('"runtime-v1"', f'"{runtime}"').replace( + hashlib.sha256(original).hexdigest(), hashlib.sha256(changed).hexdigest(), + ) + environment = os.environ.copy() + environment.update({"PLUGIN_ROOT": str(root), "PLUGIN_DATA": str(data), "PYTHONDONTWRITEBYTECODE": "1"}) + result = subprocess.run( + [sys.executable, "-c", code], + input=json.dumps({"session_id": "next-release", "hook_event_name": "SessionStart"}), + text=True, capture_output=True, env=environment, check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + if expected is None: + self.assertEqual(result.stdout, "") + self.assertIn("runtime collision or corruption", result.stderr) + else: + self.assertEqual(json.loads(result.stdout)["runtime"], expected, result.stderr) + if __name__ == "__main__": unittest.main()