feat(webconfig): implement /api/cli on the device

The terminal has been driving the mock since it was built. This is the firmware
side, so it works on hardware.

Same 202 + reqid + poll contract as a config save, for the same reason:
CommonCLI touches prefs, the radio and the filesystem, none of which may be
reached from the async_tcp task. Commands go into the deferred slot and tick()
drains them on the loop task. Unlike a save this is not allowlisted — reaching
what the serial console reaches is the point, and execCommand() already passes
sender_timestamp 0, so the terminal gets exactly the serial console's
privilege. Authentication is the boundary, as it is there.

The CLI shares the config batch's slot rather than owning a second MAX_BATCH
array: both drain on the loop task, both are single-slot, and a duplicate would
cost ~8 KB of permanently resident RAM. Sharing also makes a save and a CLI run
mutually exclusive, which they must be. Each reader checks the kind, so neither
can serve the other's results.

Three things the mock could not have taught us:

  - Board::reboot() does not return, so a drained `reboot` would take the node
    down before the client read a single result. It is answered rather than
    executed, and the batch arms the existing deferred-reboot path once the
    results have been read — withheld if any command failed, exactly as a save
    withholds one. clkreboot/poweroff/ota update do real work on the way down
    and cannot be faked, so they still drop the connection; the UI warns first.
  - `password <new>` echoes the new password in its reply. The config path
    already scrubbed that by key; a CLI entry has no key, so it is matched on
    the command. CLI commands are also kept out of the serial log entirely —
    the browser session and the serial console are different audiences.
  - MAX_BATCH is 24, not the 64 the page assumed. It is reported as
    status.max_cmds instead of hardcoded, so the cap cannot drift.

Results stream and page (kCliResultPage = 8), and "done" means the client has
been handed every result, not merely that execution finished — otherwise a
client that stops polling at "done" loses the last page. Commands are never
echoed back: they may carry a secret, and the client matches by index.

New decisions live in WebConfigBatch.h with the rest, covered by three host
tests. Builds clean for heltec_v4_repeater_observer_mqtt; 22 batch + 14 keys
tests pass; the CLI audit reports 119/119 against the updated mock.
This commit is contained in:
agessaman
2026-08-07 23:16:38 -07:00
parent 33d8766d48
commit d7109c185c
7 changed files with 446 additions and 37 deletions
@@ -190,6 +190,47 @@ TEST(WebConfigBatch, StopWarnsOnceAfterTheDeadlineThenKeepsWaiting) {
EXPECT_EQ(Batch::StopAction::Wait, Batch::stopStep(2, false, 0, 999999));
}
// --------------------------------------------------------------------------
// CLI sequences (/api/cli), which share the deferred-command slot
// --------------------------------------------------------------------------
TEST(WebConfigBatch, CliReadPagesResultsAndNeverOverrunsWhatHasDrained) {
const int page = Batch::kCliResultPage;
// Nothing drained past the cursor yet.
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/0, /*produced=*/0, page));
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/3, /*produced=*/3, page));
// Partial progress: hand back exactly what exists.
EXPECT_EQ(3, Batch::cliPageCount(0, 3, page));
EXPECT_EQ(2, Batch::cliPageCount(5, 7, page));
// More available than fits in one read: cap at the page size.
EXPECT_EQ(page, Batch::cliPageCount(0, page + 5, page));
// A cursor beyond what has drained (stale or crafted) yields nothing rather
// than a negative count that would index backwards through the batch.
EXPECT_EQ(0, Batch::cliPageCount(/*from=*/9, /*produced=*/4, page));
}
TEST(WebConfigBatch, CliReadIsDoneOnlyOnceEveryResultHasBeenHandedOver) {
// Still executing: never final, however much has been read.
EXPECT_FALSE(Batch::cliReadIsFinal(State::Pending, /*from=*/0, /*page=*/8, /*total=*/8));
// Execution finished but the client has only seen the first page. Reporting
// "done" here would make a client that stops polling lose the rest.
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/0, /*page=*/8, /*total=*/20));
EXPECT_FALSE(Batch::cliReadIsFinal(State::Done, /*from=*/8, /*page=*/8, /*total=*/20));
// The read that hands over the last result is the final one.
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/16, /*page=*/4, /*total=*/20));
// Re-reading past the end stays final (polls after the last page).
EXPECT_TRUE(Batch::cliReadIsFinal(State::Done, /*from=*/20, /*page=*/0, /*total=*/20));
}
TEST(WebConfigBatch, CliRebootIsWithheldWhenAnyCommandInTheSequenceFailed) {
EXPECT_TRUE(Batch::cliRebootAllowed(/*has_reboot=*/true, /*all_ok=*/true));
// Same rule a config save follows: do not reboot into a half-applied config
// over a link the operator may not get back.
EXPECT_FALSE(Batch::cliRebootAllowed(true, false));
// No `reboot` in the sequence: nothing to allow either way.
EXPECT_FALSE(Batch::cliRebootAllowed(false, true));
EXPECT_FALSE(Batch::cliRebootAllowed(false, false));
}
// --------------------------------------------------------------------------
// Wrap-around guard shared with the production _reboot_at assignments
// --------------------------------------------------------------------------