Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 20 additions & 3 deletions bridge/test_lan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -121,6 +123,7 @@ extends = env:stackchan
; Provision after flashing with:
; wifi set ssid "<name>" pass "<password>" 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 =
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -539,6 +546,7 @@ build_src_filter =
-<*>
+<sd_provisioner.cpp>
extra_scripts =
pre:tools/platformio_reproducible_build.py
pre:tools/platformio_sd_format_gate.py
build_unflags =
-std=gnu++11
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions tools/platformio_reproducible_build.py
Original file line number Diff line number Diff line change
@@ -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'")