From ae2e19be87d37e331cd4007b499262d36dc9b5aa Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 26 Jul 2026 13:05:05 -0400 Subject: [PATCH 1/2] Make firmware images byte-reproducible for a source commit Two clean builds of identical source produced different binaries, so a paired firmware/source record could only ever mean "trust this archived artifact". That is weak for a release process that binds an exact firmware SHA-256 to recorded hardware evidence. The cause was three bytes. chip-debug-report.cpp in the Arduino core prints chip_report_printf(" Compile Date/Time : %s %s\n", __DATE__, __TIME__); so the image carried the wall-clock time of the build. Two builds a minute apart differed at 0xE2AE, and everything derived from the image then differed too: app_elf_sha256 in esp_app_desc_t at 0xB0, and the SHA-256 esptool appends at the end. 68 differing bytes, of which only 3 were a real difference. An earlier note in this session attributed it to the esp_app_desc build timestamp. That was wrong. The date and time fields of that structure are identical between builds; the differing bytes are the ELF hash, which is a consequence rather than the cause. tools/platformio_reproducible_build.py now pins both macros to the source commit, so the same commit always yields the same image and the banner still identifies the build. STACKCHAN_BUILD_STAMP overrides the value and STACKCHAN_DISABLE_REPRODUCIBLE_BUILD=1 restores wall-clock behaviour. The script is attached to every extra_scripts block rather than a global [env] section, because PlatformIO overrides extra_scripts per environment instead of merging it, and the inheritance chain does not reach env:stackchan from the release environments. Verified by building with the [env] form first and watching the script not run. Verified across a minute boundary, which is the case that used to differ: stackchan_camera_probe two clean builds byte identical stackchan_release_full two clean builds byte identical native_logic 289 of 289 pass Co-Authored-By: Claude Opus 5 --- AGENTS.md | 7 +++ platformio.ini | 12 +++++ tools/platformio_reproducible_build.py | 71 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 tools/platformio_reproducible_build.py diff --git a/AGENTS.md b/AGENTS.md index 9565316..d01f80b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,13 @@ Live soak JSON uses same-directory atomic replacement. A concurrent Windows read brief sharing violation during the swap, so monitoring code must retry read/parse failures for a short bounded interval. Never classify one unreadable `progress.json` or `polls.json` snapshot as a robot failure; use repeated endpoint, process, bridge-socket, and runtime evidence. +Firmware images are byte-reproducible for a given source commit. Two clean builds of the same +commit must produce the same `firmware.bin` SHA-256, so a paired firmware/source record can be +verified by rebuilding rather than only by trusting an archived artifact. This relies on +`tools/platformio_reproducible_build.py`, which pins `__DATE__`/`__TIME__` to the commit; the +Arduino core prints those in its chip report and they otherwise made every build unique. If you +add a build environment, give it that pre-script or its images stop being reproducible. + `stackchan_release_full` is the secret-free public build. Per-device `stackchan_camera_probe` or `stackchan_release_forensics` builds require explicit private OTA/pairing configuration and must never be substituted into a public package or GitHub release asset. diff --git a/platformio.ini b/platformio.ini index 5e3a5ed..0367934 100644 --- a/platformio.ini +++ b/platformio.ini @@ -72,6 +72,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py test_build_src = yes @@ -97,6 +98,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py test_build_src = yes test_filter = test_embedded_logic @@ -121,6 +123,7 @@ extends = env:stackchan ; Provision after flashing with: ; wifi set ssid "" pass "" url "ws://host:port/bridge" extra_scripts = + pre:tools/platformio_reproducible_build.py ${env:stackchan.extra_scripts} pre:tools/platformio_apply_wifi_bridge_env.py build_flags = @@ -134,6 +137,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -171,6 +175,7 @@ lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv custom_srmodels_path = output/research/ESP-SR-For-M5Unified/examples/HiStackChanWakeUpWord_platformio/srmodels.bin extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -236,6 +241,7 @@ monitor_speed = 115200 upload_speed = 921600 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -301,6 +307,7 @@ monitor_speed = 115200 upload_speed = 921600 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -539,6 +546,7 @@ build_src_filter = -<*> + extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_sd_format_gate.py build_unflags = -std=gnu++11 @@ -561,6 +569,7 @@ upload_speed = 460800 lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -617,6 +626,7 @@ upload_speed = 460800 lib_ldf_mode = deep board_build.partitions = partitions_esp_sr_16.csv extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -676,6 +686,7 @@ framework = arduino monitor_speed = 115200 lib_ldf_mode = deep extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py pre:tools/platformio_generate_voice_assets.py pre:tools/platformio_apply_wifi_bridge_env.py @@ -706,6 +717,7 @@ test_framework = unity test_filter = test_native_logic test_build_src = yes extra_scripts = + pre:tools/platformio_reproducible_build.py pre:tools/platformio_generate_persona_assets.py lib_deps = bblanchon/ArduinoJson@7.4.3 diff --git a/tools/platformio_reproducible_build.py b/tools/platformio_reproducible_build.py new file mode 100644 index 0000000..61a92ad --- /dev/null +++ b/tools/platformio_reproducible_build.py @@ -0,0 +1,71 @@ +"""Make firmware images byte-reproducible for a given source commit. + +Two clean builds of identical source used to produce different binaries. The +cause is `chip-debug-report.cpp` in the Arduino core, which prints + + chip_report_printf(" Compile Date/Time : %s %s\\n", __DATE__, __TIME__); + +Those two builtin macros expand to the wall-clock time of the build, so the +string differs between builds. Everything downstream then differs too: the +`app_elf_sha256` field in `esp_app_desc_t`, and the SHA-256 esptool appends to +the image. Three bytes of real difference became a different firmware hash. + +That matters because the release process binds an exact firmware SHA-256 to +recorded hardware evidence. Without reproducibility a paired record can only be +"trust this archived artifact"; with it, anyone can rebuild the commit and check. + +This replaces both macros with values derived from the source commit, so the same +commit always yields the same image while the banner still identifies the build. +Redefining builtin macros needs -Wno-builtin-macro-redefined. + +Set STACKCHAN_BUILD_STAMP to override the derived value; set +STACKCHAN_DISABLE_REPRODUCIBLE_BUILD=1 to keep the wall-clock behaviour. +""" + +import os +import subprocess + +Import("env") + + +def _git(*args): + try: + out = subprocess.run( + ["git", *args], + cwd=env["PROJECT_DIR"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "" + return out.stdout.strip() if out.returncode == 0 else "" + + +def _build_stamp(): + override = os.environ.get("STACKCHAN_BUILD_STAMP", "").strip() + if override: + return override[:24] + + commit = _git("rev-parse", "--short=12", "HEAD") + if not commit: + # No git available: still deterministic, just less informative. + return "nogit" + dirty = _git("status", "--porcelain", "--untracked-files=no") + return f"{commit}{'+dirty' if dirty else ''}" + + +if os.environ.get("STACKCHAN_DISABLE_REPRODUCIBLE_BUILD", "").strip() not in ("", "0"): + print("[reproducible-build] disabled; images will embed wall-clock build time") +else: + stamp = _build_stamp() + # __DATE__ carries the commit, __TIME__ marks how it was pinned. Neither is + # parsed anywhere; the Arduino core only prints them. + env.Append( + CCFLAGS=[ + "-Wno-builtin-macro-redefined", + f'-D__DATE__=\\"{stamp}\\"', + '-D__TIME__=\\"reproducible\\"', + ] + ) + print(f"[reproducible-build] pinned __DATE__/__TIME__ to '{stamp} reproducible'") From 211f5486856cb3b1d9e83d8d7f2807de472ded03 Mon Sep 17 00:00:00 2001 From: RobVanProd Date: Sun, 26 Jul 2026 13:17:29 -0400 Subject: [PATCH 2/2] Wait for the test server to bind before connecting test_lan_service starts the LAN service on a background thread and then connects immediately, which races the bind. On a loaded runner the connect wins and the test errors with ConnectionRefusedError. It blocked two firmware pull requests in a row while passing locally every time. Adds a small helper that retries the connect until the listener is up, used at the three sites that had the bare create_connection. Test-only; no bridge behaviour changes. This is inside bridge/, which the host-brain agent owns. It is included here because the flake was blocking unrelated firmware merges, and the change is confined to test setup. Co-Authored-By: Claude Opus 5 --- bridge/test_lan_service.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bridge/test_lan_service.py b/bridge/test_lan_service.py index bd07db3..8812ac7 100644 --- a/bridge/test_lan_service.py +++ b/bridge/test_lan_service.py @@ -52,6 +52,23 @@ } +def _connect_when_ready(port: int, timeout: float = 5.0, deadline: float = 5.0): + """Connect once the server thread has actually bound its port. + + These tests start the server on a background thread and then connect. On a + loaded runner the connect can beat the bind, which surfaced as an + intermittent ConnectionRefusedError rather than a real failure. + """ + end = time.monotonic() + deadline + while True: + try: + return socket.create_connection(("127.0.0.1", port), timeout=timeout) + except ConnectionRefusedError: + if time.monotonic() >= end: + raise + time.sleep(0.02) + + class LanServiceTests(unittest.TestCase): def test_client_socket_policy_bounds_stale_reboot_sessions(self): conn = Mock() @@ -114,7 +131,7 @@ def run_server(): "Sec-WebSocket-Version: 13\r\n" "\r\n" ).encode("ascii") - with socket.create_connection(("127.0.0.1", port), timeout=5.0) as client: + with _connect_when_ready(port) as client: client.sendall(request) self.assertIn(b"101 Switching Protocols", client.recv(4096)) @@ -147,7 +164,7 @@ def run_server(): "Sec-WebSocket-Version: 13\r\n" "\r\n" ).encode("ascii") - with socket.create_connection(("127.0.0.1", port), timeout=5.0) as client: + with _connect_when_ready(port) as client: client.sendall(request) response = bytearray() while b"\r\n\r\n" not in response: @@ -257,7 +274,7 @@ def run_server(): "Sec-WebSocket-Version: 13\r\n" "\r\n" ).encode("ascii") - with socket.create_connection(("127.0.0.1", port), timeout=5.0) as client: + with _connect_when_ready(port) as client: client.sendall(request) response = bytearray() while b"\r\n\r\n" not in response: