Correctness:
- {path_distance} was always blank in production. The resolution code that
builds repeater_info dropped latitude/longitude in every branch, so the
calculator could never find a coordinate. My tests passed hand-built
dicts straight to the calculator and never exercised the builder, which
is why they stayed green. Coordinates are now carried through all four
construction sites, and the new tests drive _lookup_repeater_names via
its lookup_func hook so the real builder runs.
- The #80 route guard was defeated two ways in the channel handler. When
the RF data was an uncorrelated fallback, control fell through to the
raw-hex and routing_info fallbacks below, which took the route from the
unrelated packet anyway; the guard had actually made that path
reachable. message.routing_info was also assigned unconditionally, and
the path command reads it. "Not attributable" is now a terminal branch
and the routing_info hand-off checks provenance.
- Same fix was incomplete for DMs: routing_info was captured and turned
into path_info before the provenance check ran, so the later check only
declined to overwrite an already-wrong value. Guarded at the source.
- Rendering could transmit for real. Capture only intercepts
send_response, but advert calls send_advert() directly and
send_response_chunked never checked capture_sink. Chunked sends are now
captured, and rendering is opt-in via BaseCommand.render_safe (default
False) instead of a denylist that cannot be complete. This also closes
the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
longer broadcast configuration to a channel.
- Multi-part rendered output was rejoined into one oversized send.
Scheduled messages are now split to the RF body budget and sent through
send_channel_messages_chunked, on character boundaries so multi-byte
text is not corrupted.
- _last_path_distance_km is instance state that was only set on success,
so an invalid path request could show the previous request's distance.
Reset at the start of every execute().
- Stale-contact retries were only counted on non-OK results, so timeouts
and exceptions left a contact eligible forever and could recreate the
storm. All failed attempts count now.
Web viewer:
- A failed config reload was reported as a successful save. The API and
UI now distinguish "saved and active" from "saved, restart needed".
- Preview count was unbounded; clamped to 1-20.
- update_ini_values is a read-modify-replace with no locking, so two
concurrent viewer saves could lose one. Serialised behind a lock.
Four defects in how configuration is written and displayed. All are
reachable without authentication when web_viewer_password is unset,
which is an explicitly supported setup.
- ini_writer: reject sections, keys and values that cannot survive the
round-trip. update_ini_values() wrote free text straight into the
file, so a newline in any value ended the key's line and everything
after it was re-parsed as INI on the next load. Saving a greeting of
"Welcome!\n[Injected]\npwned = true" through the plugin settings
endpoint created a real new section; a repeated section name bricked
startup with DuplicateSectionError. The check lives at the writer
because three separate callers reach it, and raises IniValueError so a
bad payload is a 400 instead of a half-written file. DEFAULT is
refused as well: its keys apply to every section, and
ConfigParser.add_section('DEFAULT') raises.
- settings_store, web_viewer: persist to disk before mirroring into the
in-memory config. The mirror ran first, so a value rejected by the
writer stayed live in the running process despite never reaching
disk. Both routes taking free-form input (the zombie and offline
alert emails) had the same ordering. The zombie route also caught
only OSError, so an IniValueError there escaped as a 500 rather than
the intended 400.
- config_snapshot: redact Discord webhook URLs. Neither
discord_webhook_urls nor the [DiscordBridge] bridge.<channel> keys
matched any redaction rule, so --show-config and /admin/config
printed live webhook secrets in full; anyone holding one can post to
the channel. Telegram's api_token was already redacted. The
bridge. prefix needs its own rule because the varying part is the
channel name, leaving no fixed stem for the substring match.
- mqtt_weather, packet_capture: verify TLS certificates by default.
Both called tls_set(cert_reqs=ssl.CERT_NONE) unconditionally, so the
broker username and password sent immediately afterwards were
readable by anyone able to intercept the connection.
BREAKING: brokers presenting self-signed certificates now fail to
connect until tls_insecure (mqtt_weather) or mqttN_tls_insecure
(packet_capture) is set to true. Both log a warning while enabled.
- meshcore_bot.py: run config validation on normal startup and print
errors/warnings (capped at 15) — misspelled sections/keys previously
failed silently, the top source of configuration confusion
- ini_writer: append new keys directly under a section's last entry
instead of after its trailing blank line
- add .claude/launch.json for browser-preview of the standalone viewer
Verified end-to-end in the browser: /plugins renders 43 command cards,
opt-in commands show off, dynamic-row add/save writes config.ini with a
timestamped backup and preserved comments.