diff --git a/codex-rs/voice-host/README.md b/codex-rs/voice-host/README.md index 9c1a80dbb9..cbb5aa656e 100644 --- a/codex-rs/voice-host/README.md +++ b/codex-rs/voice-host/README.md @@ -70,18 +70,20 @@ to reap a dropped helper; explicit `close` waits for process exit. For private feasibility artifacts, `third_party/voice/assemble_package.py` copies an existing validated package into a fresh output and adds the helper. Supply -`--package`, `--helper`, `--voice-target`, `--build-commit`, and `--output`. +`--package`, `--helper`, `--voice-target`, `--build-commit`, `--output`, and +`--runtime `. Linux MUSL apps require same-architecture GNU helpers; other targets must match. The package version must end in `+`. The manifest records declared build provenance and file hashes, not authentication or binary architecture proof. -Add `--runtime ` to include the platform preparer's selected -libraries and `runtime.json` beside the helper. The assembler checks the target, +The required runtime includes the platform preparer's selected libraries and +`runtime.json` beside the helper. The assembler checks the target, pinned source manifest, plugin list, relative paths and file hashes, then checks the copied hashes again. It preserves `lib/` and `plugins/` on macOS, `lib/` and `lib/gstreamer-1.0/` on Linux, and the shared `bin/` on Windows. Unlisted files are not copied. The package manifest records every included runtime file and the unchanged runtime receipt. -Omitting `--runtime` retains helper-only assembly. +Helper-only assembly is no longer supported: native bindings require shared +libraries before the helper enters `main`, including for lifecycle calls. This accepts a development runtime receipt, not an authenticated release. It does not repeat native loader inspection or establish trust in the build inputs. @@ -93,7 +95,7 @@ because GStreamer registers process-global callbacks. The small private C ABI bootstrap does not expose native pointers to the parent or link native libraries into ordinary Codex. The existing `libloading` dependency supplies OS loading. Media/privacy controls, a full media binding layer and actual audio proof remain -integration stages; helper-only packages continue to support lifecycle calls. +integration stages; a prepared runtime is required for packaged lifecycle calls. The ignored `packaged_runtime` integration test uses real libraries prepared for the host platform. From `codex-rs`, run diff --git a/third_party/voice/assemble_package.py b/third_party/voice/assemble_package.py index 7397f651ac..c54c1aa4a3 100644 --- a/third_party/voice/assemble_package.py +++ b/third_party/voice/assemble_package.py @@ -1,4 +1,4 @@ -"""Add a helper and optional prepared runtime to a fresh private Codex package.""" +"""Add a helper and its prepared runtime to a fresh private Codex package.""" import argparse import hashlib @@ -21,7 +21,7 @@ def assemble( commit: str, output: Path, *, - runtime: Path | None = None, + runtime: Path, ): package, helper = package.resolve(strict=True), helper.resolve(strict=True) output = output.absolute() @@ -71,17 +71,15 @@ def assemble( raise ValueError("helper and app entrypoint must be regular files") if not suffix and not helper.stat().st_mode & 0o111: raise ValueError("helper is not executable") - inputs = {} - if runtime is not None: - runtime = runtime.resolve(strict=True) - if any(parent.samefile(package) for parent in (runtime, *runtime.parents)): - raise ValueError("runtime must be outside the input package") - if any( - parent.exists() and parent.samefile(runtime) - for parent in output.resolve().parents - ): - raise ValueError("output must be outside the runtime input") - inputs = runtime_files(runtime, voice_target) + runtime = runtime.resolve(strict=True) + if any(parent.samefile(package) for parent in (runtime, *runtime.parents)): + raise ValueError("runtime must be outside the input package") + if any( + parent.exists() and parent.samefile(runtime) + for parent in output.resolve().parents + ): + raise ValueError("output must be outside the runtime input") + inputs = runtime_files(runtime, voice_target) output.mkdir() # Exclusive creation: never clean or overwrite a pre-existing output. try: shutil.copytree(package, output, dirs_exist_ok=True) @@ -126,7 +124,10 @@ if __name__ == "__main__": parser.add_argument("--build-commit", required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument( - "--runtime", type=Path, help="prepared development runtime to include unchanged" + "--runtime", + type=Path, + required=True, + help="prepared development runtime required by the helper", ) args = parser.parse_args() assemble( diff --git a/third_party/voice/test_assemble_package.py b/third_party/voice/test_assemble_package.py index f3116272e8..79f929ab5e 100644 --- a/third_party/voice/test_assemble_package.py +++ b/third_party/voice/test_assemble_package.py @@ -297,12 +297,14 @@ class AssembleTests(unittest.TestCase): ) def test_copies_app_unchanged_and_records_distinct_linux_targets(self): + runtime, receipt = self.make_runtime() assemble( self.package, self.helper, "aarch64-unknown-linux-gnu", self.commit, self.output, + runtime=runtime, ) self.assertEqual((self.output / "bin/codex").read_bytes(), b"unchanged app") self.assertEqual((self.package / "bin/codex").read_bytes(), b"unchanged app") @@ -326,11 +328,19 @@ class AssembleTests(unittest.TestCase): "codex-resources/voice/bin/codex-voice-host": hashlib.sha256( b"private helper" ).hexdigest(), + **{ + f"codex-resources/voice/{r['path']}": r["sha256"] + for r in receipt["libraries"] + }, + "codex-resources/voice/runtime.json": digest( + runtime / "runtime.json" + ), }, }, ) def test_rejects_incompatible_targets_and_unstamped_or_mixed_builds(self): + runtime, _ = self.make_runtime() for target, commit in [ ("aarch64-unknown-linux-musl", self.commit), ("x86_64-unknown-linux-gnu", self.commit), @@ -341,7 +351,14 @@ class AssembleTests(unittest.TestCase): self.subTest(target=target, commit=commit), self.assertRaises(ValueError), ): - assemble(self.package, self.helper, target, commit, self.output) + assemble( + self.package, + self.helper, + target, + commit, + self.output, + runtime=runtime, + ) self.assertFalse(self.output.exists()) def test_assembles_matching_gnu_linux_app_and_helper_targets(self): @@ -352,8 +369,16 @@ class AssembleTests(unittest.TestCase): (self.package / "codex-package.json").write_text( json.dumps(self.metadata) ) - output = self.root / target - assemble(self.package, self.helper, target, self.commit, output) + runtime, receipt = self.make_runtime(target) + output = self.root / (target + " packaged") + assemble( + self.package, + self.helper, + target, + self.commit, + output, + runtime=runtime, + ) manifest = json.loads( (output / "codex-resources/voice/manifest.json").read_text() ) @@ -370,6 +395,13 @@ class AssembleTests(unittest.TestCase): "codex-resources/voice/bin/codex-voice-host": hashlib.sha256( b"private helper" ).hexdigest(), + **{ + f"codex-resources/voice/{r['path']}": r["sha256"] + for r in receipt["libraries"] + }, + "codex-resources/voice/runtime.json": digest( + runtime / "runtime.json" + ), }, }, ) @@ -382,6 +414,7 @@ class AssembleTests(unittest.TestCase): ) def test_never_replaces_existing_or_nested_outputs(self): + runtime, _ = self.make_runtime() for output in (self.package, self.package / "nested", self.helper): with self.subTest(output=output), self.assertRaises(ValueError): assemble( @@ -390,11 +423,13 @@ class AssembleTests(unittest.TestCase): "aarch64-unknown-linux-gnu", self.commit, output, + runtime=runtime, ) self.assertEqual(self.helper.read_bytes(), b"private helper") self.assertFalse((self.package / "nested").exists()) def test_cleans_only_its_new_copy_on_failure(self): + runtime, _ = self.make_runtime() with patch("assemble_package.shutil.copy2", side_effect=OSError("copy failed")): with self.assertRaises(OSError): assemble( @@ -403,6 +438,7 @@ class AssembleTests(unittest.TestCase): "aarch64-unknown-linux-gnu", self.commit, self.output, + runtime=runtime, ) self.assertFalse(self.output.exists()) self.assertEqual((self.package / "bin/codex").read_bytes(), b"unchanged app")