Require a prepared runtime when assembling voice helper packages (#43121)

## Why

The helper's native bindings require shared libraries before entering `main`, including for lifecycle calls. Helper-only packages cannot satisfy that requirement.

## What changed

Make `--runtime` mandatory in `third_party/voice/assemble_package.py` and require the `runtime` argument in `assemble()`. Always validate and include the prepared runtime, and update the voice host documentation to describe the requirement.

## Testing

Update assembly tests to supply prepared runtimes and verify that package manifests include runtime library hashes and `runtime.json` alongside the helper.

GitOrigin-RevId: ab5abf80ae935b4ace833fa59b74f670ba62c632
This commit is contained in:
Benjamin Carlsson
2026-09-05 23:09:26 +00:00
committed by copyberry
parent f6976ab036
commit e01f38c388
3 changed files with 61 additions and 22 deletions
+7 -5
View File
@@ -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 <prepared-runtime>`.
Linux MUSL apps require same-architecture GNU helpers; other targets must match.
The package version must end in `+<build-commit>`. The manifest records declared
build provenance and file hashes, not authentication or binary architecture proof.
Add `--runtime <prepared-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
+15 -14
View File
@@ -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(
+39 -3
View File
@@ -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")