#!/usr/bin/env python3 """build-step-bundle.py โ€” the TRANSITION bundle for a release whose bundle ADDS a path (R-880, `11` ยง5.4.2). Usage: python3 scripts/build-step-bundle.py (prints the sha256) WHY. A box's INSTALLED felhom-os-apply checks every path of an incoming bundle against ITS OWN table (rule R16) โ€” so a bundle that adds a path (v0.146.1 adds felhom-priv-apply, the guest hook and the shared-parent files, R-861) is refused by every box still running an older wrapper. The fix is a step: first a bundle the old wrapper accepts that brings ONLY the new felhom-os-apply (the new table), then the release's own bundle, which the new wrapper accepts. WHAT IT BUILDS. is the bundle the boxes run now (download it from the package registry, e.g. felhom-agent/0.145.0/felhom-config-bundle.json, and check its sha against the hub's record). The step bundle is that bundle with EXACTLY ONE change: the /usr/local/sbin/felhom-os-apply entry's content is replaced by configs/felhom-os-apply (this tree). Same paths, same modes, same checks, every other byte identical; agent_version is (e.g. 0.146.1-step1). The old wrapper verifies it like any bundle (signature, sha, R16, content checks, self-check of the new wrapper) โ€” nothing about the trust route changes. Pinned by configs/test_felhom_config_bundle.py (StepBundle): same paths as the base, only the wrapper differs, the new wrapper's table is a superset of the base's paths. """ import base64 import hashlib import json import pathlib import re import sys REPO = pathlib.Path(__file__).resolve().parent.parent OSAPPLY_DEST = "/usr/local/sbin/felhom-os-apply" def build_step(base_bytes, version, new_osapply_bytes): if not re.match(r"^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.]+$", version): raise SystemExit(f"build-step-bundle: {version!r} must be a semver with a step suffix, e.g. 0.146.1-step1") base = json.loads(base_bytes) files = base.get("files") if base.get("format") != 1 or not isinstance(files, list): raise SystemExit("build-step-bundle: the base is not a format-1 bundle") hit = [e for e in files if e.get("path") == OSAPPLY_DEST] if len(hit) != 1: raise SystemExit(f"build-step-bundle: the base has {len(hit)} {OSAPPLY_DEST} entries, want exactly 1") hit[0]["content_b64"] = base64.b64encode(new_osapply_bytes).decode() hit[0]["sha256"] = hashlib.sha256(new_osapply_bytes).hexdigest() base["agent_version"] = version return (json.dumps(base, indent=1, sort_keys=True) + "\n").encode() def main(argv): if len(argv) != 4: print(__doc__, file=sys.stderr) return 2 data = build_step(pathlib.Path(argv[1]).read_bytes(), argv[2], (REPO / "configs" / "felhom-os-apply").read_bytes()) pathlib.Path(argv[3]).write_bytes(data) print(hashlib.sha256(data).hexdigest()) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))