fix(webconfig): tighten CLI failure detection, reboot deferral and refusals

Five findings from review, all confirmed against the source.

Failure classification (P2). Testing replies for an "Err" prefix passed five
other shapes off as success: "Unknown command", "unknown config: x", "??: x",
"Can't find GPS", "(ERR: clock cannot go backwards)" and "File system erase:
Err". They rendered green, and worse, left _batch_all_ok true — so a queued
reboot went ahead after commands that had failed, defeating the gate entirely.

Rather than lengthen one guess, the two questions are now asked separately,
each erring safe:

  - colour asks "does this look like a failure", against every shape CommonCLI
    actually emits, enumerated in WebConfigBatch.h and pinned by a host test
    that uses the literal strings. Getting this wrong is cosmetic.
  - the reboot gate asks something narrower and answerable: "did every setting
    I asked for take". Only `set`/`password` gate it, and only on the "OK"
    prefix every setter keeps. Diagnostics no longer gate a reboot at all, so a
    harmless `memory` cannot strand one and no guess is made about "> value".

Reboot deferral (P2). CommonCLI dispatches on a six-byte prefix, so `reboot
now` and `rebooted` reach Board::reboot() too. Matching exactly meant those
variants skipped both the confirmation and the deferral and took the node down
mid-drain — the precise failure deferral exists to prevent. Both sides now
anchor the way the firmware dispatches, and the UI's risk matcher with them.

Three commands the portal cannot honestly serve are refused at POST with a
reason, and dropped from autocomplete, instead of running and lying:

  - `start ota` builds a second AsyncWebServer on port 80 with no bind check
    and answers "Started" regardless; the portal already holds that port, so it
    could only leak the allocation and inhibit sleep.
  - `clock sync` takes its time from the caller's timestamp, which a web
    request has none of, so CommonCLI always rejected it. `time <epoch>` works
    and remains offered.
  - bare `log` and `get acl` write their real output to Serial and hand back a
    stub the terminal showed as success; `log` also streams a whole file from
    the loop task, stalling the mesh and radio while it does.

The mock now emits the same failure shapes it used to fake as successes, so
these are reproducible off-hardware. 24 batch + 14 keys tests pass; audit
reports 119/119 answered, 0 missing, 4/4 refused with a reason.
This commit is contained in:
agessaman committed 2026-08-08 08:40:49 -07:00
1 parent 0f4c87118a
commit 944caebed7
6 files changed
+284 -47

No files matched your search

+24 -2
View File
@@ -73,9 +73,15 @@ COMMAND_SOURCES = [
"examples/simple_repeater/MyMesh.cpp",
]
# Firmware commands the table deliberately does not offer.
# Firmware commands the table deliberately does not offer. Everything below
# except tls.bundletest is also rejected by /api/cli (wcCliUnavailable), so the
# portal never pretends to run something it cannot.
NOT_OFFERED = {
"tls.bundletest", # TLS debugging, not an operator command
"tls.bundletest", # TLS debugging, not an operator command
"start ota", # binds port 80, which the portal is already using
"clock sync", # takes its time from the caller; a web request has none
"log", # streams to Serial and stalls the radio ("log start" is offered)
"get acl", # streams to Serial, returns nothing
}
@@ -202,6 +208,22 @@ def main():
% (getc, got, want, setr["reply"]))
failures.append((getc, got))
# Commands the portal refuses must be refused clearly, not run and fudged.
print("\nrefused with a reason : ", end="")
refused = []
for cmd in sorted(NOT_OFFERED - {"tls.bundletest"}):
try:
cli._sequence([cmd])
refused.append((cmd, "was accepted, expected a 400"))
except urllib.error.HTTPError as e:
body = json.load(e) if e.code == 400 else {}
if e.code != 400 or not body.get("error"):
refused.append((cmd, "HTTP %d, expected 400 with a reason" % e.code))
print("%d/%d" % (len(NOT_OFFERED) - 1 - len(refused), len(NOT_OFFERED) - 1))
for cmd, why in refused:
print(" FAIL %-30s %s" % (cmd, why))
failures += refused
print("\n%s" % ("FAILED: %d" % len(failures) if failures else "all clear"))
return 1 if failures else 0