diff --git a/codex-rs/voice-host/README.md b/codex-rs/voice-host/README.md index ad8780dd37..17d0f3e130 100644 --- a/codex-rs/voice-host/README.md +++ b/codex-rs/voice-host/README.md @@ -24,5 +24,16 @@ an existing validated package into a fresh output and adds the helper. Supply 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. -Native loading, media/privacy controls and actual audio proof remain subsequent -integration stages; this assembler does not add native runtime libraries. +Add `--runtime ` to include 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. + +This accepts a development runtime receipt, not an authenticated release. It +does not repeat native loader inspection or establish trust in the build inputs. +The helper still does not load these files; native loading, media/privacy controls, +linking against the prepared SDK and actual audio proof remain integration stages. diff --git a/third_party/voice/BUILD.bazel b/third_party/voice/BUILD.bazel index c0d0e7d64e..345480c33b 100644 --- a/third_party/voice/BUILD.bazel +++ b/third_party/voice/BUILD.bazel @@ -35,6 +35,7 @@ filegroup( "build_native.py", "linux_runtime.py", "macos_runtime.py", + "package_runtime.py", "runtime.py", "windows_runtime.py", ":source_inputs", diff --git a/third_party/voice/assemble_package.py b/third_party/voice/assemble_package.py index 74a4908552..7397f651ac 100644 --- a/third_party/voice/assemble_package.py +++ b/third_party/voice/assemble_package.py @@ -1,4 +1,4 @@ -"""Add a lifecycle-only helper to a fresh private copy of a canonical Codex package.""" +"""Add a helper and optional prepared runtime to a fresh private Codex package.""" import argparse import hashlib @@ -6,9 +6,23 @@ import json from pathlib import Path import re import shutil +import sys + +# Import only this script's siblings, including under PYTHONSAFEPATH. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from package_runtime import runtime_files +from runtime import digest -def assemble(package: Path, helper: Path, voice_target: str, commit: str, output: Path): +def assemble( + package: Path, + helper: Path, + voice_target: str, + commit: str, + output: Path, + *, + runtime: Path | None = None, +): package, helper = package.resolve(strict=True), helper.resolve(strict=True) output = output.absolute() if ( @@ -57,6 +71,17 @@ def assemble(package: Path, helper: Path, voice_target: str, commit: str, output 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) output.mkdir() # Exclusive creation: never clean or overwrite a pre-existing output. try: shutil.copytree(package, output, dirs_exist_ok=True) @@ -64,10 +89,19 @@ def assemble(package: Path, helper: Path, voice_target: str, commit: str, output destination = output / relative_helper destination.parent.mkdir(parents=True) shutil.copy2(helper, destination) + for relative, expected_digest in inputs.items(): + copied = output / "codex-resources/voice" / relative + copied.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(runtime / relative, copied) + if digest(copied) != expected_digest: + raise ValueError("runtime file changed during copying") digests = {} for relative in (entrypoint, relative_helper): with (output / relative).open("rb") as source: digests[relative] = hashlib.file_digest(source, "sha256").hexdigest() + digests.update( + {f"codex-resources/voice/{name}": value for name, value in inputs.items()} + ) manifest = { "schemaVersion": 1, "buildCommit": commit, @@ -91,7 +125,15 @@ if __name__ == "__main__": parser.add_argument("--voice-target", required=True) 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" + ) args = parser.parse_args() assemble( - args.package, args.helper, args.voice_target, args.build_commit, args.output + args.package, + args.helper, + args.voice_target, + args.build_commit, + args.output, + runtime=args.runtime, ) diff --git a/third_party/voice/package_runtime.py b/third_party/voice/package_runtime.py new file mode 100644 index 0000000000..73bdc02dff --- /dev/null +++ b/third_party/voice/package_runtime.py @@ -0,0 +1,75 @@ +"""Validate prepared runtime files before copying them into a private package. + +The runtime receipt records preparation, not authenticity. Preserve its bytes and +layout; native loader inspection remains the platform preparer's responsibility. +""" + +import hashlib +import json +from pathlib import Path +import re + +from runtime import PLUGINS, digest + + +def runtime_files(root: Path, target: str) -> dict[str, str]: + manifest_path = root / "runtime.json" + if manifest_path.is_symlink() or not manifest_path.is_file(): + raise ValueError("runtime manifest must be a regular file") + with manifest_path.open("rb") as source: + data = source.read(1024 * 1024 + 1) + if len(data) > 1024 * 1024: + raise ValueError("runtime manifest exceeds limit") + manifest = json.loads(data) + if ( + manifest.get("schemaVersion") != 1 + or manifest.get("developmentOnly") is not True + or manifest.get("target") != target + or manifest.get("sourceManifestSha256") + != digest(Path(__file__).with_name("sources.json")) + or not re.fullmatch(r"[0-9a-f]{40}", manifest.get("sourceCommit", "")) + ): + raise ValueError( + "runtime receipt does not match the source inputs and helper target" + ) + if target.endswith("-apple-darwin"): + pattern, plugin = ( + r"(?:lib|plugins)/[A-Za-z0-9_+.-]+\.dylib", + "plugins/libgst{}.dylib", + ) + elif target.endswith("-unknown-linux-gnu"): + pattern, plugin = ( + r"lib/(?:gstreamer-1\.0/)?[A-Za-z0-9_+.-]+\.so(?:\.[0-9]+)*", + "lib/gstreamer-1.0/libgst{}.so", + ) + elif target.endswith("-pc-windows-msvc"): + pattern, plugin = r"bin/[A-Za-z0-9_+.-]+\.[dD][lL][lL]", "bin/gst{}.dll" + else: + raise ValueError("unsupported native runtime target") + libraries = manifest.get("libraries", []) + if not 1 <= len(libraries) <= 128: + raise ValueError("unexpected runtime inventory size") + files, names = {}, set() + for record in libraries: + name, expected = record["path"], record["sha256"] + if not re.fullmatch(pattern, name) or name.casefold() in names: + raise ValueError("invalid or colliding runtime path") + path = root / name + if ( + path.parent.is_symlink() + or path.is_symlink() + or not path.is_file() + or not path.resolve().is_relative_to(root) + ): + raise ValueError("runtime entries must be regular files inside the input") + if not re.fullmatch(r"[0-9a-f]{64}", expected) or digest(path) != expected: + raise ValueError("runtime file digest mismatch") + names.add(name.casefold()) + files[name] = expected + plugins = sorted(plugin.format(name) for name in PLUGINS) + if sorted(manifest.get("plugins", [])) != plugins or not set(plugins).issubset( + files + ): + raise ValueError("runtime must include exactly the selected plugins") + files["runtime.json"] = hashlib.sha256(data).hexdigest() + return files diff --git a/third_party/voice/test_assemble_package.py b/third_party/voice/test_assemble_package.py index 149a2138b6..862db02694 100644 --- a/third_party/voice/test_assemble_package.py +++ b/third_party/voice/test_assemble_package.py @@ -2,12 +2,17 @@ import hashlib import json +import os from pathlib import Path +import shutil +import subprocess +import sys import tempfile import unittest from unittest.mock import patch from assemble_package import assemble +from runtime import PLUGINS, digest class AssembleTests(unittest.TestCase): @@ -36,6 +41,230 @@ class AssembleTests(unittest.TestCase): self.helper.chmod(0o755) self.output = self.root / "installed copy" + def make_runtime( + self, target="aarch64-unknown-linux-gnu", plugin="lib/gstreamer-1.0/libgst{}.so" + ): + root = self.root / target + root.mkdir() + libraries = [] + for name in PLUGINS: + path = root / plugin.format(name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"prepared {name}".encode()) + libraries.append( + {"path": path.relative_to(root).as_posix(), "sha256": digest(path)} + ) + manifest = { + "schemaVersion": 1, + "developmentOnly": True, + "target": target, + "sourceCommit": "b" * 40, + "sourceManifestSha256": digest(Path(__file__).with_name("sources.json")), + "plugins": [record["path"] for record in libraries], + "libraries": libraries, + } + (root / "runtime.json").write_text(json.dumps(manifest)) + return root, manifest + + def test_cli_packages_each_runtime_layout_without_changing_bytes(self): + for target, plugin in ( + ("aarch64-unknown-linux-gnu", "lib/gstreamer-1.0/libgst{}.so"), + ("aarch64-apple-darwin", "plugins/libgst{}.dylib"), + ("aarch64-pc-windows-msvc", "bin/gst{}.dll"), + ): + with self.subTest(target=target): + runtime, receipt = self.make_runtime(target, plugin) + (runtime / "unlisted-file").write_bytes(b"must not ship") + windows = target.endswith("windows-msvc") + entrypoint = "bin/codex.exe" if windows else "bin/codex" + self.metadata.update(target=target, entrypoint=entrypoint) + (self.package / entrypoint).write_bytes(b"unchanged app") + (self.package / "codex-package.json").write_text( + json.dumps(self.metadata) + ) + output = self.root / (target + " packaged") + subprocess.run( + [ + sys.executable, + str(Path(__file__).with_name("assemble_package.py").resolve()), + "--package", + str(self.package), + "--helper", + str(self.helper), + "--voice-target", + target, + "--build-commit", + self.commit, + "--output", + str(output), + "--runtime", + str(runtime), + ], + check=True, + capture_output=True, + cwd=self.root, + env={**os.environ, "PYTHONSAFEPATH": "1"}, + ) + voice = output / "codex-resources/voice" + self.assertFalse((voice / "unlisted-file").exists()) + expected = {r["path"]: r["sha256"] for r in receipt["libraries"]} + expected["runtime.json"] = digest(runtime / "runtime.json") + self.assertEqual( + {name: digest(voice / name) for name in expected}, expected + ) + self.assertEqual( + {name: digest(runtime / name) for name in expected}, expected + ) + manifest = json.loads((voice / "manifest.json").read_text()) + helper_name = "codex-voice-host.exe" if windows else "codex-voice-host" + self.assertEqual( + manifest["sha256"], + { + entrypoint: digest(self.package / entrypoint), + f"codex-resources/voice/bin/{helper_name}": digest(self.helper), + **{ + f"codex-resources/voice/{name}": value + for name, value in expected.items() + }, + }, + ) + + def test_rejects_invalid_runtime_receipts_before_creating_package(self): + runtime, original = self.make_runtime() + changes = [ + {"target": "x86_64-unknown-linux-gnu"}, + {"developmentOnly": False}, + {"sourceManifestSha256": "0" * 64}, + {"sourceCommit": "dev"}, + {"plugins": original["plugins"][:-1]}, + {"libraries": []}, + {"libraries": original["libraries"] * 20}, + {"libraries": original["libraries"] + [original["libraries"][0]]}, + ] + for name in ( + "../outside.so", + "lib/../outside.so", + "bin/codex-voice-host", + "lib/evil:stream.so", + ): + changes.append({"libraries": [{**original["libraries"][0], "path": name}]}) + for change in changes: + with self.subTest(change=change), self.assertRaises(ValueError): + (runtime / "runtime.json").write_text( + json.dumps({**original, **change}) + ) + assemble( + self.package, + self.helper, + original["target"], + self.commit, + self.output, + runtime=runtime, + ) + self.assertFalse(self.output.exists()) + + def test_rejects_runtime_digest_changes_and_nested_output(self): + runtime, receipt = self.make_runtime() + nested = runtime / "new package" + with self.assertRaisesRegex(ValueError, "outside the runtime"): + assemble( + self.package, + self.helper, + receipt["target"], + self.commit, + nested, + runtime=runtime, + ) + self.assertFalse(nested.exists()) + (runtime / receipt["plugins"][0]).write_bytes(b"changed") + with self.assertRaisesRegex(ValueError, "digest mismatch"): + assemble( + self.package, + self.helper, + receipt["target"], + self.commit, + self.output, + runtime=runtime, + ) + self.assertFalse(self.output.exists()) + + def test_rejects_runtime_inside_input_package(self): + runtime, receipt = self.make_runtime() + nested = self.package / "runtime-staging" + runtime.rename(nested) + (nested / "unlisted-file").write_bytes(b"must not ship") + sources = [nested, self.package] + alias = self.package.with_name("APP") / "runtime-staging" + if alias.exists(): + sources.append(alias) + for source in sources: + with ( + self.subTest(source=source), + self.assertRaisesRegex(ValueError, "outside the input package"), + ): + assemble( + self.package, + self.helper, + receipt["target"], + self.commit, + self.output, + runtime=source, + ) + self.assertFalse(self.output.exists()) + + def test_rejects_symlinked_runtime_library_directories(self): + runtime, receipt = self.make_runtime() + outside = self.root / "outside" + (runtime / "lib/gstreamer-1.0").rename(outside) + try: + (runtime / "lib/gstreamer-1.0").symlink_to( + outside, target_is_directory=True + ) + except OSError as error: + self.skipTest(f"symlink creation unavailable: {error}") + with self.assertRaisesRegex(ValueError, "regular files"): + assemble( + self.package, + self.helper, + receipt["target"], + self.commit, + self.output, + runtime=runtime, + ) + self.assertFalse(self.output.exists()) + + def test_copy_revalidation_removes_only_new_output(self): + runtime, receipt = self.make_runtime() + original_copy = shutil.copy2 + for changed_name in (receipt["plugins"][0], "runtime.json"): + + def changed_copy(source, destination, **kwargs): + result = original_copy(source, destination, **kwargs) + if source == runtime.resolve() / changed_name: + Path(destination).write_bytes(b"changed after validation") + return result + + with ( + self.subTest(changed_name=changed_name), + patch("assemble_package.shutil.copy2", changed_copy), + ): + with self.assertRaisesRegex(ValueError, "changed during copying"): + assemble( + self.package, + self.helper, + receipt["target"], + self.commit, + self.output, + runtime=runtime, + ) + self.assertFalse(self.output.exists()) + self.assertEqual( + json.loads((runtime / "runtime.json").read_text()), receipt + ) + self.assertEqual( + (self.package / "bin/codex").read_bytes(), b"unchanged app" + ) + def test_copies_app_unchanged_and_records_distinct_linux_targets(self): assemble( self.package,