The configuration API could leave a REJECTED request half applied, and a
second request could replace the known-good recovery bank with settings
nobody had proved. Both are fixed here, together with the validation
limits that the API and the boot loader did not share.
1. A rejected request changed the node.
nvs_set_* writes through to flash in the setter itself. In ESP-IDF 5.4
nvs_open() hands back an NVSHandleSimple, whose setters call
Storage::writeItem() straight away and whose commit() only returns
ESP_OK -- closing the handle does NOT discard what was written. The
handler validated and wrote in the same pass, so
{"wifi_ssid":"wrong-network","edge_tier":"invalid"} answered 400 with
the SSID already changed and no recovery bank armed: the next reboot
could leave the node unreachable with no way back.
The request is now validated in full before anything is written, so a
body with one bad field changes nothing at all. The recovery bank is
built from the CURRENT values and persisted to the trial namespace
BEFORE the first live write, so the node can always get back. A write
that fails part way through restores the whole bank and disarms the
trial instead of leaving a mix of old and new values.
2. A second request could bank unproven settings as the fallback.
s_trial_armed is only set at boot, so the three seconds between
accepting a change and rebooting onto it were a window in which a
second request passed the guard and banked the FIRST request's
unproven settings as the recovery bank -- a revert would then restore
settings that were never shown to work.
A separate pending-reboot state closes the window and answers 409
while a change is in flight. It is deliberately not s_trial_armed: an
association event on the OLD running configuration clears
s_trial_armed and erases the trial bank, which would unlock the API
exactly when it must stay locked.
3. The API accepted settings the boot loader silently ignored.
The API table and the loader now share one definition of every
accepted range, in config_limits.h, so they cannot drift again. The
ranges that had drifted are tightened to what the loader actually
applies:
vital_window 8..4096 -> 32..256
top_k_count 1..64 -> 1..32
channel_hop_count 1..14 -> 1..6 (NVS_CFG_HOP_MAX, the size
of the channel list)
A value the API accepts but the loader ignores is stored, reported as
applied, and then replaced by the default at boot -- it looks applied
and is not.
The value rules move to config_rules.c, free of ESP-IDF and of cJSON, so
the host test in test/test_config_rules.c pins them against the same
translation unit the firmware links. The fuzzer now takes its bounds
from the same header instead of repeating them as literals. A number
that cannot be represented is rejected instead of being truncated into a
value that looks valid: the old code cast before it range-checked, so
1e300 became an arbitrary integer.
The acceptance criteria for this change are the four checks in the
review: a mixed valid/invalid payload must answer 400 and leave NVS
untouched, a second change within three seconds must answer 409, the
out-of-range values must be rejected before any write, and a failure
while persisting the recovery bank must leave the live configuration
alone.
Verified: make run_config_rules (host, no device) passes; the rest of
the host test suite is unchanged.
The gating "Rust Dependency Audit" job ran
cargo audit --file v2/Cargo.lock --json
from the repository root. cargo-audit 0.22 resolves its config file as
./.cargo/audit.toml relative to the CURRENT WORKING DIRECTORY, never
relative to the --file lockfile it audits, and it has no flag to point at a
config path. From the repository root that file does not exist, so the job
audited with cargo-audit's defaults and applied NONE of the reviewed ignores
in v2/.cargo/audit.toml — every run of the job reports "ignore":[] in its own
JSON artifact, which is the machine-readable proof that the policy file was
never read.
Run the audit inside the v2 workspace so that v2/.cargo/audit.toml is loaded,
and record the discovery rule in the file header so the next reader does not
move the policy file back out of reach.
Also record the three zip advisories, with the reachability proof in the file:
RUSTSEC-2026-0367 SUID/SGID bits survive extraction
RUSTSEC-2026-0368 extract() creates a symlink pointing outside the destination
RUSTSEC-2026-0369 extract() applies permissions through such a symlink
All three are fixed only in zip >= 9.0.0, and no published release of the
parents moves there — ndarray-npy 0.10.0 -> zip ^6, candle-core 0.11.0 ->
zip ^8.6, tch/torch-sys 0.26.0 -> zip ^0.6 — so the bump is not reachable
today.
They are also not reachable from this workspace. All three live in the
extraction path (ZipArchive::extract / ZipStreamReader::extract) and nothing
in the resolved graph calls either one: ndarray-npy and candle-core use
ZipArchive::new plus by_name / by_index and ZipWriter, and torch-sys
extracts the libtorch archive with its own per-entry loop (archive.by_index
+ fs::File::create + io::copy), so it never calls extract(), never creates a
symlink and never applies the archive's Unix permission bits. The single
archive it does extract is the pinned libtorch build downloaded over HTTPS
from download.pytorch.org.
proc-macro-error2 (RUSTSEC-2026-0173) is added next to the existing
proc-macro-error entry for the same reason: unmaintained build-time
proc-macro, no runtime exposure.
Verified: cargo audit --file Cargo.lock from v2/ reports 0 vulnerabilities and
exits 0; the policy file now loads 30 documented ignores.
cargo-audit reports five advisories against the checked-in v2/Cargo.lock
that are cleared by patch-level bumps:
wasmtime 36.0.18 -> 36.0.19 RUSTSEC-2026-0379, RUSTSEC-2026-0380
rustls-webpki 0.103.15 -> 0.103.16 RUSTSEC-2026-0382
webpki-roots 1.0.7 -> 1.0.10 RUSTSEC-2026-0381
webpki-root-certs 1.0.6 -> 1.0.10 RUSTSEC-2026-0383
The two wasmtime advisories are Cranelift miscompilations on aarch64: one
can jump to the wrong target on a large `br_table`, the other overflows
silently while bit-packing. The three webpki advisories are crypto-failures
where a distrusted emSign intermediate — whose private key is public — could
be used to build a certificate chain the verifier accepts; the fix removes the
emSign ECC Root CA - G3 anchor that made those paths reachable.
These crates are transitive, so the requirement is only enforceable through
the lockfile that the gating Security Scanning job audits. v2/Cargo.toml now
records the floors next to the existing quinn-proto / rustls / wasmtime ones,
so a plain `cargo update` cannot walk them backwards without explanation.
Verified: cargo audit --file Cargo.lock reports 0 vulnerabilities and exits 0
with the reviewed policy loaded; cargo check --workspace --no-default-features
is clean.
Reporter MEASURED on ESP32-S3: vitals/feature packets 2 -> 59 per 60 s.
Not reproducible on C6 (EDGE_MAX_SUBCARRIERS=256); C6 build verified only.
Co-Authored-By: claude-flow <ruv@ruv.net>
npm test (vitest run) collected tests/a11y.spec.ts, a Playwright spec,
and failed. Exclude tests/** from Vitest and pass when no unit tests
exist; Playwright specs still run via npm run test:e2e / test:a11y.
Co-Authored-By: claude-flow <ruv@ruv.net>
nvsim-server -> ghcr.io was failing for three separate reasons, each one
masked by the previous. The Dockerfile carried two literals that had drifted
from their source of truth, and both workflows hard-coded the `ruvnet`
registry namespace that only the upstream can write to.
nvsim-server/Dockerfile
1. Stub workspace version. The Dockerfile builds a throwaway workspace
containing only nvsim and nvsim-server. That stub hard-coded
version = "0.3.0" (PR #440); the real workspace is now 0.3.1 (PR
#1439) and nvsim-server requires nvsim = { path = "../nvsim",
version = "0.3.1" }. The build died with "failed to select a version
for the requirement nvsim". The stub now extracts the version from
the real Cargo.toml and fails loudly if it cannot read one.
2. Builder toolchain. FROM was pinned to rust:1.81 while
rust-toolchain.toml declares channel = "1.89", so async-compression
0.4.42 and compression-codecs 0.4.38 (both MSRV 1.83, reached through
tower-http's compression-gzip feature) aborted the release build with
"rustc 1.81.0 is not supported". The builder is now 1.89.
Rather than re-pinning another literal that will drift again, the
declared channel travels into the build context and is compared
against the effective rustc before compiling. Bumping one without the
other now fails the build loudly instead of resurfacing as an MSRV
error three steps later. It is copied under a separate name on
purpose: letting rustup resolve it would silently download a
toolchain and hide the drift.
nvsim-server-docker.yml
The image name was hard-coded to ghcr.io/ruvnet/nvsim-server, so the push
failed with `denied: The requested installation does not exist` outside
the upstream namespace. It now derives from github.repository_owner,
which resolves to ruvnet for the upstream and to the fork owner for
every fork, so the workflow keeps working unchanged here.
The smoke test also pulled :sha-<short> but ran :latest. `latest` is
emitted with `enable={{is_default_branch}}`, so a tag build never had
it, and the `|| latest` fallback masked a failed push by testing a stale
image. It now pulls and runs the sha tag, which type=sha emits on every
event.
sensing-server-docker.yml
Same namespace problem: the image was fixed to ruvnet/wifi-densepose on
both Docker Hub and ghcr.io, which a fork cannot push to. Publishing
under github.repository_owner fixes that.
docker login was also unconditional, so a repository without
DOCKERHUB_USERNAME/DOCKERHUB_TOKEN died at "Must provide --username with
--password-stdin" before building anything. ghcr.io needs no secret, so
it stays the always-on target and Docker Hub is added only when both
credentials are present.
Verified: cargo check -p nvsim-server --release under rustc 1.89.0
compiles nvsim v0.3.1, nvsim-server v0.3.1 and async-compression 0.4.42;
the resulting image builds, pushes and its /api/health reports
{"nvsim_version":"0.3.1"}. CI on this branch runs the full pipeline
green.
Three independent fixes that each resolve a red job on main.
Security floors (v2/Cargo.lock, v2/Cargo.toml)
cargo audit reported 10 advisories across three transitive crates:
quinn-proto 0.11.15, rustls 0.23.45 and wasmtime 36.0.16. Bump to
0.11.18 / 0.23.46 / 36.0.18. All three arrive through
midstreamer-quic = 0.3, so only the lockfile enforces them; v2/Cargo.toml
now records the floors so a plain `cargo update` cannot walk them back
without explanation.
Dashboard accessibility
18 axe failures (3 browsers x 6 views): 58 role=switch toggles with no
accessible name, two unlabelled range inputs, low-contrast timestamp /
chip / badge text, and two scrollable regions with no keyboard access.
The welcome modal also left the accessibility tree only by fading to
opacity 0. A closed state at opacity 0 still gets scanned, and axe
composes the semi-transparent text over the darkened backdrop, which
produced a false color-contrast failure (ratio 1.55 reported where the
real ratio was ~17:1). It now leaves via visibility, and
connectedCallback exposes a `ready` attribute once its async storage read
settles so the smoke test stops sampling the modal mid-transition.
README visual asset validation
The workflow regenerates the 20 SVGs and requires byte-identical output.
scripts/render-readme-visuals.py stripped unused CSS rules but left the
trailing newline behind, so 14 removed rules collapsed into 16 blank
lines. The producer now consumes the orphan newline and the assets are
regenerated (94,576 -> 94,027 bytes).
Verified locally: cargo audit 0.22.2 exits 0, cargo check --workspace
--no-default-features --all-targets is clean, the README renderer is
idempotent with verify passing, and the axe suite is 18/18 across chromium,
firefox and webkit on six consecutive runs.
An ESP32-C6 node whose uplink PHY rate drops to 1-2 Mbps stays there for
24-425 min without help, and that one slow node fills the channel for every
node on it. A fresh association restarts rate control and restores the rate.
POST /link/reassoc (same PSK auth as /config) replies, then drops the link
200 ms later; the existing STA_DISCONNECTED path reconnects with its usual
backoff. A 60 s floor returns 429 to a runaway caller. GET /config reports
reassoc_count and reassoc_last_s_ago. Detection stays with the caller.
Measured on a 9-node C6 fleet, 10-09..10-16: 15 of 15 requests that reached
the node restored its rate within 3 minutes.
Stacked on #1827 (remote configuration).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows, files whose basenames match DOS reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) cannot be created, causing provision.py to fail with FileNotFoundError whenever Windows COM ports (e.g. COM7) were used. This also caused 16 test failures in the esp32-csi-node test suite on Windows.
- Prefix reserved device names with '_' in _state_path_for.
- Add legacy unprefixed path fallback in load_state and load_board_state for backward compatibility.
- Add regression tests in test_provision_state.py for reserved device prefixing.
- Add harness/homecore/** text=auto eol=lf in .gitattributes to maintain byte-for-byte provenance and prevent manifest.sha256 mismatch on Windows checkouts.
Validation: All 73 esp32-csi-node tests pass on Windows, and all 120 harness/ruview security tests, package manifest, and brain verification pass.
Integrate reviewed source PRs #2201-#2223, #2225-#2231, #2235-#2240, #2242 and #2243 (firmware README hunks remain in #2247). Regenerate one package manifest after integration, validate native Windows test execution, bound MTK listener startup, support official C6 release bundles, and retain stronger release metadata gates.