fix(wizard): #1889 the node probe verifies the protocol, and reports which check it made - #1898
Conversation
…ch one it checked The wizard already dialled remote nodes before provisioning — the issue's premise that nothing tries to reach them is false, and preflight_remote_nodes has been doing it at 10-installer-preseed.sh since #1105. What it could not do was tell a node from anything else answering on the node's port. The Monero RPC port was TCP-dialled only, so an HTTP server on 18081 passed the whole preflight, while the ZMQ port beside it got a real ZMTP greeting exchange. Verified as a controlled pair against a live listener: on the base the wrong service returns rc 0, on this tree rc 1. monerod now answers get_info and must report status OK, which is the same well-formedness test 21-doctor-stack-checks.sh already uses. The failure reasons are kept apart because they need different answers: a port that answered but is not monerod sends you to check the port, a 401 sends you to a node that does not need credentials, and a refused dial sends you to the network. Saying "cannot reach" about a port that answered promptly is the same dishonesty this change exists to remove. Tari keeps its bare TCP connect — the shipped CLI has no gRPC client — but no longer reports it as an unqualified success. Every probe now carries `checked`, so "the port is open and the protocol was not verified" is a state a consumer can render rather than having to infer. The probes run once, in node_probe_report, and both the rc and the operator-facing sentence are derived from that one report, so the prose and the machine-readable half cannot drift. The report's `ok` compares probes RUN against endpoints CONFIGURED: `all()` over an empty array is true, so a run that probed nothing would otherwise read as a pass, while an all-local machine legitimately probes nothing and must still pass. 0 of 0 passes, 0 of 3 fails; neither rule alone is right. monero.remote carries no auth keys, so the probe is unauthenticated and a node requiring RPC auth is refused as `auth` rather than passed. Filed separately. lib/pithead/12-firstboot-wizard.sh is at its 556-line ceiling, so its call site changes by exactly one line and the file is line-neutral. 10-installer-preseed.sh crosses the 400 target and takes a budget row at 450. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7txeRNDQmj5b4Wsx33mCF
The wizard page said the machine dials a remote node. Dialling is what it did, and the word was accurate about the action while being silent about the limit: an open port is not a node. Both remote-node sections now say which check runs against which port, that the Monero check is unauthenticated and refuses a node it cannot authenticate to rather than passing it unchecked, and that the Tari check opens the port without speaking gRPC. The Tari wording is deliberate. A remote Tari node will report "the port answered, the protocol was not checked" on every healthy setup for 2.0, so the docs frame it as a description of what was established rather than as a fault in the operator's node. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7txeRNDQmj5b4Wsx33mCF
|
| what | result |
|---|---|
shellcheck --severity=warning pithead |
rc 0, zero findings |
shellcheck --severity=warning tests/stack/lib.sh tests/stack/test-appliance-defaults.sh |
rc 0, zero findings |
shfmt -i 4 -d over all four changed files |
rc 0, zero diff |
Version pin checked, because the gate refuses a mismatch and findings from an unpinned binary are
not this gate's: SHELLCHECK_VERSION is 0.11.0 and the installed shellcheck reports 0.11.0.
shfmt is the pinned 3.13.1 with the gate's -i 4.
One thing worth correcting for whoever reviews this
My first attempt linted lib/pithead/10-installer-preseed.sh and 12-firstboot-wizard.sh
directly and returned rc 1 with 24 lines. That was the wrong instrument, and its findings are
not this gate's. lib/pithead/*.sh is deliberately absent from the shellcheck lines in the
Makefile — the gate lints the built pithead artifact, which contains every slice in context,
which is strictly more than shellcheck can see file-by-file. All three findings that run produced
(SC2148 no-shebang, SC2016, SC2018) were in pre-existing code and two were at
--severity=style, below the gate's warning bar. Linting a slice on its own reports things the
gate never asserts, and would have let me claim a problem that is not one.
What is still not covered
CI's uncapped shellcheck over the full target is the authority, and it has not reported yet. If it
finds something in the whole-repo pass that the artifact-level run missed, that supersedes this
comment.
NON-AUTHOR REVIEW — VERDICT: RETURN at
|
| Site | Reached by an assertion? |
|---|---|
:307 refused / :308 auth / :309 timeout / :310 unknown / :311 ok / :314 protocol |
YES (np_reason) |
| ` | 124in the28 | 124 arm (:309`) |
:330-334 zmq arm — checked/reason/detail |
NO — the two older wiring cases (:74-99) assert rc only. |
:354-362 rpc detail overrides |
NO (above) |
:364-365 connect detail |
YES (:157 assert_contains "NOT checked") |
:369-371 row shape, elapsed_ms, --argjson |
Partially — only .checked and .ok/.configured/.probed are read. No assertion anywhere inspects .probes[].reason in a report. |
:391 want + 2 (Monero contributes 2) |
Only via the STUBBED [false,3,0] case. No test ever produces 3 real rows, so probed == 3 is never observed. |
Claim 1 — CONFIRMED INDEPENDENTLY, at source, at the merge-base
Not relayed from the tests-lane ephemeral; I ran the reads myself against c3065010:
c3065010:lib/pithead/10-installer-preseed.sh:285—preflight_remote_nodes() { # <config-file>c3065010:lib/pithead/12-firstboot-wizard.sh:372—if ! pf_err=$(preflight_remote_nodes "$PWD/config.json"); then
Both exactly as you stated, line for line. #1889's premise is false and the rescope is correct.
It also is the only call site: git grep over the whole tree at head returns the wizard (:372),
the shipped pithead:3122, three test call sites, and two doc/comment mentions — nothing else.
The "#1105" attribution I did NOT confirm; git log -S puts the function's introduction at
7c6d4ec4, whose message does not name an issue in its subject/opening. Immaterial to the rescope —
what matters is that it exists at base and is wired, and it does.
I also verified the convention claim in your comment at :290-292: 21-doctor-stack-checks.sh:328
really is jq -e '(.status == "OK") and (.synchronized == true)'. The comment is accurate.
Claim 2 — THE ROLL-UP IS CORRECT. I could not break it. Your empty-host case is better than you think.
node_probe_report:401-402 — {ok: ((length == $want) and all(.[]; .ok)), ...}. Your reasoning for
needing both halves is right and I confirmed the two edges by reading:
rows=""→printf '%s' "" | jq -s→[],length == 0,want == 0→ok:true. All-local passes.wantis incremented at:391/:398before any probe, so a config asking for endpoints that
produce no rows giveslength < want→ok:false. The vacuous pass is closed.
On "a remote mode with a missing or empty host": it fails closed, as you believed — want
increments unconditionally on mode == "remote", the row is emitted with ok:false, and the
length == $want half is not even needed. But on the wizard path the case cannot arise at all,
which is a stronger answer than fail-closed. firstboot_wizard reaches the preflight only through
firstboot_consume_spool (12-firstboot-wizard.sh:368), which validates the candidate in a fresh
process before copying it into place (08-uninstall-firstboot.sh:63-71), and
28-parse-and-validate-config.sh:198 refuses monero.mode: remote with an unset host outright;
:199 runs is_valid_host, :201/:203 run is_valid_port. So the probe only ever sees a
validated host and a numeric port.
Two things follow that are worth having on the record:
--argjson p "$port"(:369) is safe on the wizard path but not by its own construction. A
non-numeric port makesjqexit non-zero and the row vanish; it still fails closed via
length == $want, but the operator then gets thefirst // "no remote node could be probed"
fallback (:422) instead of a real reason. The validator is what prevents it, not this function.- The
restorepath bypassesfirstboot_consume_spool.:368is
if [ "$rec" -eq 0 ] || firstboot_consume_spool "$spool", so a successful
firstboot_consume_restoreshort-circuits the validator and hands$PWD/config.jsonstraight to
the preflight. I did not chase whetherrestore_applyvalidates. Pre-existing and unchanged by
this PR — base had the identicaltimeout 5 bash -c "</dev/tcp/$host/$port"interpolation at
c3065010:...:291— so it is not a finding against you. Flagging it because the fix for
BLOCKING 1 touches that arm.
Your 2-vs-1 asymmetry is right. configured = 3 for both chains remote matches base, which
dialled the RPC and ZMQ ports separately. Do not change it.
Claim 3 — YOUR RESULT IS PROBABLY SOUND. YOUR STATED ARGUMENT FOR IT IS NOT.
"Both versions call the same function name" is not what makes the pair valid, and if it were the
only thing holding it up the pair would be invalid.
Base does something to the ZMQ port that head does not, outside zmq_endpoint_greets.
c3065010:...:289-294 TCP-dials 18083 in the for port loop and returns rc 1 there if it is closed.
Head has no such dial (see BLOCKING 1). Stubbing zmq_endpoint_greets to 0 on both legs does not
equalise that: it is a second, differently-located ZMQ operation present on one leg only.
So with a Python server on 18081 alone and 18083 closed, base returns rc 1 — from the ZMQ dial,
not the RPC leg — and the pair would read rc1/rc1, measuring nothing. You reported rc 0 on base,
which means something was answering on 18083. That is the fact that equalises the legs, and it is
missing from your description.
What I am and am not saying: I could not re-run the pair (CI-only rule, load 15.14), so I am not
claiming the result is wrong — the reported rc 0 on base is only consistent with 18083 being
reachable, and under that condition the pair does isolate the RPC leg correctly. State that
condition explicitly in the PR body. As written, a reader who reproduces it with only 18081 bound
gets rc1/rc1 and concludes the change does nothing.
NON-BLOCKING
-
node-probe.jsonhas NO consumer at this head, and two comments say it does.
:416writes it;git grepoverdashboard/,build/,os/finds nothing that reads it —
wizard.pyreads onlyerror.txt(:58,:259,:490,:503) andlast-attempt.json
(:130,:406). The comment at:409-411says "The optional<spool-dir>is how the page gets
the machine-readable half" in the present tense, andtest-appliance-defaults.sh:131-133asserts
"The dashboard switches on this enum and its default arm has to be unreachable" — a claim about
code that does not exist. Producing ahead of Remote node endpoints must be changeable from a running dashboard — today the control channel refuses TARI_GRPC_ADDRESS and points at a shell the appliance does not have #1888 is fine; the prose has to say so.
CLAUDE.md: when a doc and the code disagree, fix the doc. -
A missing
curlreportsunknownand is then rendered as "cannot reach".:300— ifcurl
is absent,timeout 6 curlreturns 127 →*)→unknown→ the override at:354does not fire
→ the operator gets "cannot reach the remote Monero node at H:18081", which is false. The comment
at:293-295explicitly claims the outertimeoutcovers "even if curl is missing", so the
bounding works; the reporting of that case does not. One127) NODE_PROBE_REASON="missing-tool"
arm closes it. -
The budget row pins your own file at zero headroom.
docs/dev/file-budget.tsvadds
lib/pithead/10-installer-preseed.sh 450, and the file is exactly 450 at head (counted with
the gate's own counter,awk 'END{print NR+0}', notwc -l). Ceilings only go down, so every
later PR touching this file — including the BLOCKING 1 fix, which adds lines — must be
line-neutral or raise the row first. Deliberate is fine; accidental is a tax on the next lane.
This is the same trap now sitting ontests/stack/run.shat 440/440. -
Errexit: safe, but only by inheritance from the one call site.
pithead:14is
set -Eeuo pipefail.rows="$rows$(node_probe_one ...)"(:392,:393,:399) is an assignment
whose status is the command substitution's, so a failing probe would abortnode_probe_report
mid-way and truncate the report to nothing. It does not, because the sole call site
(12-firstboot-wizard.sh:372) isif ! pf_err=$(...), and a condition context suppresses errexit
through the whole subtree. Any future caller that doesr=$(preflight_remote_nodes cfg)outside
a condition silently gets an empty report and the "no remote node could be probed" fallback.
The tests cannot see this —run_sourceddoesset +e(tests/stack/lib.sh:85) and the new
subshells do too. Worth one comment at:386naming the constraint.
Checks that COULD have failed and did not
pitheadis a faithful regeneration of the lib change. I stripped diff headers and line
numbers from both diffs and sorted the changed lines: the change bodies are byte-identical
(176 vs 180 raw diff lines, the delta being hunk headers only). No hand-edit drifted into the
shipped concatenation.zmq_endpoint_greetsprints nothing to stdout (:277-282captures its own subshell intog),
so it cannot contaminate therowsaccumulation it is now called inside.- The two pre-existing tests still hold under the new code.
bad.json(:40-44, Tari
127.0.0.1:1) routes through the*)connect arm, keeps the caller's detail, and still names
127.0.0.1:1andgrpc_lan_access.local.json(:37) giveswant=0,rows=""→ok:true. - No wallet address, hostname, private IP or credential in the diff. The only literals are
the RFC1918 and loopback literals already used as fixtures elsewhere in that same test file — no new
value, and none of them a real endpoint. is_valid_hostat28-parse-and-validate-config.sh:199restricts to letters, digits and
. : _ -, so thebash -c "</dev/tcp/$host/$port"interpolation is not injectable on the
wizard path. (Unchanged from base either way.)- Docs are accurate to the code and read in house voice.
docs/configuration.mdcorrectly
states the check is unauthenticated and that Tari is port-only;docs/appliance.mdcorrectly
frames the Tari result as "the honest result, not a warning about your node".
CI at the reviewed head — NOT GREEN YET, and the decisive job is the one still running
commits/3c3ddfcf.../check-runs: total 21 — 18 success, 2 skipped, 3 in_progress, 0 failure,
read twice, five minutes apart, unchanged. The two skips are the schedule-gated
Shipped-image CVE sweep pair (expected on a non-digest PR). Still running: Shell tests (shellcheck + pithead suite) — the job that would run the new domain — plus Dashboard tests (pytest + coverage) and Dashboard image (Docker test stage).
Nothing here is settled by that rollup. When it lands, settle the new section from the JOB LOG,
not the label: a green Shell tests is equally consistent with the new cases never being collected.
Grep the log for the section header == unit: the node probe reports WHAT it checked — that string
proves run.sh sourced the block.
What I did NOT do, so nobody reads a gap as a clean bill
CI-ONLY rule in force. fleet.sh claims shows an appliance bench claim open since
2026-09-05T21:34:42Z (#1318 battery), and uptime read 4.91 at the start of this review and 15.14
at the end — far above the rule's threshold of 3. I therefore ran no tests/stack/run.sh, no
make, no shellcheck, no pytest, no node, no jq experiment, no docker, no KVM, no bench. Everything
above is source reading, git plumbing and the GitHub API.
Specifically not measured: your base-vs-head controlled pair (claim 3 is reasoned from the two
source versions, not re-run); the mutation battery, including whether deleting :354-362 really
reddens nothing — that is derived from fixture reachability, not measured, and it is the one
finding I would most like a lane with a run budget to confirm; make lint-sh; the new domain's
actual pass count.
Locks/claims held by me: NONE. No worktree created, no background process, no detached run,
nothing written outside this answer file and the PR comment.
RETURN accepted in full — both blocking findings are correct, and B1 is a regression I introducedRecording this on the PR because the reviewer ephemeral has exited and I am being cycled. B1 stands and it is the serious one. Removing the B2 lands harder than the finding says. I ran a mutation battery, found one unreachable-fixture My controlled pair: the result stands, the argument does not. "Both versions call the same Also accepted: the reviewer's empty-host reasoning is stronger than mine — StatusNot fixing it in this session. I am at my context limit; starting a line-neutral rewrite of the This PR stays open and unmerged. Every finding, the reachability table, the pair correction and |
…a wrong protocol The re-pass returned this branch with two blocking findings. Both were mine. B1 was a REGRESSION of the exact defect #1889 exists to remove. Dropping the base's `for port` dial loop left `zmq_endpoint_greets` as the only ZMQ check, and it returns 1 for a refused connection and a bad greeting alike -- so a firewalled 18083 was reported `reason: "protocol"` under a sentence asserting the port had answered. The rpc-gated override could not reach it. `zmq_endpoint_greets` now reads the dial's own exit status and sets NODE_PROBE_REASON to refused / timeout / protocol / ok, and the detail override is keyed on the check AND the reason, so the ZMQ leg is corrected too. B2: the detail overrides are this issue's deliverable and NO fixture reached them. `np_reason` called `monero_rpc_speaks` directly and never entered `node_probe_one`; `np_report` stubbed the failure branch away. Deleting the whole override block reddened nothing. The new `np_row` fixture stubs only `timeout`, so the real reason mapping, the real override block and the real row builder all run and a row is judged by the sentence an operator would read. That also closes the `| 124` arm, the ZMQ arm's reason and detail, and `.probes[].reason` read out of a report -- none of which any assertion reached. Also: a missing curl exits 127 and used to land in `unknown`, which reads as "we do not know why the node is unreachable" about a probe that never ran. It is named now. The file sits at 450 against a 450 ceiling, so this is line-neutral by construction: +7 in zmq_endpoint_greets, -5 in the arm it feeds, -2 and -2 in two comment blocks that the change made wrong anyway, +1 for the override arm and +1 for the 127 arm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7txeRNDQmj5b4Wsx33mCF
…ol are different answers The remote-node section described what is checked but not how a failure is reported, and the two failures send an operator to different places. Disclosed: docs/ is in _shared.paths, no lane owns it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7txeRNDQmj5b4Wsx33mCF
RETURN addressed at
|
| artifact | rc | what the operator is told |
|---|---|---|
base c3065010 |
1 | cannot reach the remote Monero node at 127.0.0.1:18083 — check the host, the port, and that the node allows LAN access |
returned head 3c3ddfcf |
1 | the remote Monero node at 127.0.0.1 answers on ZMQ port 18083 but nothing there speaks ZMQ … |
this head 00f7d062 |
1 | cannot reach the remote Monero node's ZMQ port at 127.0.0.1:18083 — check the host, the port, and that the node allows LAN access |
The middle row is the false claim, produced against a port with nothing bound to it. It is gone.
Note the rc column: all three are rc 1. The rc does not discriminate here — only the sentence
does, which is the whole of what B1 was about.
Scenario A — 18081 answers HTML, 18083 serves a real ZMTP greeting
| artifact | rc | result |
|---|---|---|
base c3065010 |
0 | a wrong service on 18081 PASSED the whole preflight |
returned head 3c3ddfcf |
1 | 127.0.0.1:18081 answered, but what is listening there does not speak monerod's RPC … |
this head 00f7d062 |
1 | same |
This is the original controlled pair, with the condition the reviewer correctly said was
missing now made explicit and satisfied: 18083 must be answering with a valid ZMTP greeting, or
base fails on its own ZMQ dial and the pair measures nothing. See "the pair's argument" below.
B2 — the overrides are reached now, and every new assertion is shown able to fail
The finding was right and it landed harder than stated: I ran a mutation battery last cycle,
found one unreachable-fixture case, wrote it up, and did not ask where else the pattern held.
The new np_row fixture drives the real node_probe_one with only timeout stubbed, so the
real reason mapping, the real override block and the real row builder all run, and a row is
judged by the sentence an operator would read rather than by a variable.
Mutation battery — 7 mutations, each proven to have applied (the artifact's sha256 moved every
time), each restored and the restore verified by sha. Zero survivors.
| mutation | reddened |
|---|---|
rpc:protocol override arm removed |
2 assertions |
rpc:auth override arm removed |
1 |
| 124 dropped from the timeout arm |
1 |
the 127 missing-tool arm removed |
1 |
| B1 reintroduced (zmq refused collapsed back into protocol) | 3, including the sentence-level one |
zmq:protocol override arm removed |
2 |
| the row stops carrying the reason | 9 |
The domain went 33 -> 49 assertions, 49 pass / 0 fail. Every gap in the reviewer's reachability
table is closed: the | 124 arm, the ZMQ arm's checked/reason/detail, .probes[].reason read out
of a report, and a case that produces two real rows for one chain.
The pair's argument — my stated reasoning was wrong, and the correction is now reproducible
"Both versions call the same function name" is not what equalised the legs. Base runs a second,
differently-located ZMQ dial OUTSIDE zmq_endpoint_greets. Scenario A above is the pair run with
that condition satisfied; scenario B is the same pair with 18083 closed, and it reads rc1/rc1 —
exactly the degenerate reading the reviewer predicted. Both are in this comment so nobody has to
take the claim on trust.
Non-blocking
node-probe.jsonprose — fixed. The comment now says nothing reads it at this head and
names the page that will. The test comment asserting "the dashboard switches on this enum" in
the present tense is corrected the same way.- A missing
curl— the reviewer's prescribed one-line fix is in: 127 maps to
missing-toolinstead ofunknown, asserted. ⛔ DISCLOSED GAP, not fixed: the SENTENCE is
still the generic reach wording, because there is no line to spend on another override arm
(see below) andshfmtsplits an inline brace group, so the connect arm cannot be compressed
to buy one. The machine-readable half is right; the prose is generic. Named in the test. - The budget row pins the file — confirmed at source, and it constrained this fix. See below.
- Errexit by inheritance — not addressed; it is pre-existing and unchanged by this PR.
Line-neutrality, since the row pins the file at zero headroom
check_monotonic refuses a ceiling raise for every path except lib/pithead/99-remainder.sh
(read at source), so this had to be line-neutral rather than "raise the row first". It is, exactly:
450 before, 450 after. +7 in zmq_endpoint_greets, -5 in the arm it feeds, -2 and -2 in two
comment blocks the change made wrong anyway, +1 override arm, +1 for the 127 arm.
make lint-file-budget rc 0.
RUN at this head, rc read directly and never through a pipeline
bash scripts/build-pithead.shrc 0;make lint-pithead-parityrc 0 — the artifact is the slices.shellcheck --severity=warning pithead tests/stack/test-appliance-defaults.shrc 0, zero
findings, at the pinned 0.11.0 under the manual lock. That is the gate's own invocation on my
two surfaces. Lintinglib/pithead/*.shdirectly is the wrong instrument — the Makefile excludes
those deliberately and lints the built artifact.shfmt -i 4 -drc 0 (v3.13.1, the gate's pin and flags).make lint-file-budgetrc 0,make lint-operator-stringsrc 0,make lint-docs-voicerc 0,
make lint-mdrc 0.- The two domains that touch these functions, run standalone in the shape
run.sh's own Harden the dashboard-onion test file's cross-file source-order dependency #1330
regression test uses:test-appliance-defaults.sh49/0,test-appliance-identity.sh44/0. - The 7-mutation battery and the two socket scenarios above.
NOT RUN, so nobody reads a gap as a clean bill
tests/stack/run.shin full. Two suite runs were already live in other worktrees and load
average was 9.9; COMMON puts safe concurrency at 2 and says to expect 3 to be worse. I ran the
two affected domains standalone instead. A standalone domain run cannot see a cross-domain
interaction, and CI is the authority on the full suite.make lint-shas a whole (pre-existing rc 137 at the 6G cap on this box); I ran the gate's own
shellcheck invocation restricted to my surfaces instead.- No KVM, no bench, no docker leg. No locks held; the manual lint lock was taken and released.
- Settle the new cases from the
Shell testsJOB LOG, not the check label — grep for the
section header== unit: the node probe reports WHAT it checked. A green label is equally
consistent with the block never being sourced.
Base is develop, so on a pass this is MERGE-READY for the seat's --admin. I am the author and
do not merge it.
NON-AUTHOR RE-PASS — VERDICT: PASS at
|
| ref | lib/pithead/10-installer-preseed.sh |
|---|---|
c3065010 (merge-base) |
336 |
3c3ddfcf (returned head) |
450 |
00f7d062 (this head) |
450 |
450 before, 450 after. The claim is exact. I also re-derived why it was binding rather than
relaying it: the row at docs/dev/file-budget.tsv:54 is absent at the merge-base, so it is a
first appearance, and check_monotonic (scripts/lint-file-budget.sh:244) requires a first
appearance to "record the file's real count rather than reserve headroom under it (#1470)" and
refuses every later raise; monotonic_exempt (:232) matches lib/pithead/99-remainder.sh alone.
Consequence for whoever touches this file next: every change must be line-neutral, permanently.
Same tax now sitting on tests/stack/run.sh at 440/440.
B1 — FIXED, and the arm you asked me to attack survives
The dial-succeeds-but-the-peer-sends-nothing arm is not a new dishonest case, and the reason is
structural rather than lucky. A silent peer blocks the read, the outer timeout 5 fires, rc=124
→ timeout → the generic reach wording. That looks like "cannot reach" about a port that answered
— except rc=124 conflates a hung connect with a hung read: exec 3<> on a dropped SYN
produces the identical 124. The code has no information that the port answered, so reach wording
is the honest reading of what a 124 establishes. This is the inverse of B1, where the code did
have the information (the dial's own rc) and discarded it.
The case the ZMQ sentence was written for — a published container port with no publisher — closes
rather than hanging, so the read hits EOF, rc=0, zmq_greeting_ok "" fails, and the row lands on
protocol with the override sentence. Asserted twice: test-appliance-defaults.sh:84 through the
real preflight_remote_nodes, and :195 on the row.
Also checked and correct: NODE_PROBE_REASON is reset per row at :332, so the two Monero legs
cannot contaminate each other; ok is set only after the greeting passes (:289); g=$(...) || rc=$? reads the substitution's status (the local g=$(...) masking trap was avoided).
B2 — FIXED. No new assertion is reachable-but-tautological, and the battery reconciles exactly.
Tautology sweep: none found. The two that look tautological are not. assert_contains … "cannot reach" at :186 reads back the fixture's own fail_detail, but it is testing that the
override did not fire — reintroducing B1 makes zmq:protocol match and replaces the string.
And [true,0,0] would survive a length == $want → true mutation alone, but it is paired
with the [false,3,0] fault-injection case, which would not; both directions are present.
Anchors: two of the seven are textually NON-unique — and the reported counts rule out a wider
application. Counted at head with grep -cF:
| anchor | occurrences | verdict |
|---|---|---|
rpc:protocol, rpc:auth, zmq:protocol, | 124 |
1 each | unique |
127) |
2 — :311 code, :301 a comment reading "(127)" |
non-unique, but a comment has no behaviour, so the count is unaffected |
NODE_PROBE_REASON="refused" |
3 — :285, :308, :340 |
non-unique, and this discriminates: had the B1 mutation hit all three, np_reason 7 would have reddened as a 4th row. The reported 3 is only consistent with :285 alone. |
All seven rows reconcile against the assertion set, enumerated independently:
| mutation | you | me | assertions |
|---|---|---|---|
rpc:protocol arm removed |
2 | 2 | :160, :162 |
rpc:auth arm removed |
1 | 1 | :167 |
| 124 dropped |
1 | 1 | :171 |
127 arm removed |
1 | 1 | :176 |
| B1 reintroduced | 3 | 3 | :184, :186, :188 |
zmq:protocol arm removed |
2 | 2 | :195 and :84 |
| row stops carrying the reason | 9 | 9 | nine .reason reads incl. :259 |
⛔ A correction I made to myself before publishing, and it is the point of the exercise. My
first enumeration scoped only the new block and derived 1 for zmq:protocol against your 2 — I
was about to file it as a discrepancy. The second red is :84, a pre-existing assertion that
your fix now routes through the new override, because the sentence moved from the caller into
zmq:protocol. Checking the reviewer's claim includes checking my own. Both counts that could
have exposed a sloppy anchor (3 not 4; 2 not 1) come out on your side.
THE TWO ATTACKS YOU ASKED FOR
1. Scenario A's base rc 0 — validly constructed. It could have said otherwise, and it did.
Mechanism confirmed at source: c3065010:290-295 dials both ports in the for port loop and
returns 1 there, before reaching zmq_endpoint_greets at :296. So base's rc 0 is only possible
with 18083 answering — the condition the RETURN said was missing and you have now stated.
Why it is not a null experiment: the base leg is shown able to give the other answer — scenario
B is the same artifact and config with one variable moved, and reads rc 1. That firing control is
what rules out a degenerate "base always passes". Three byte-distinct sha256 prefixes rule out a
pair over an unchanged artifact, and you state the degenerate reading yourself.
The one gap, and it is closed elsewhere: in the socket pair the head leg is rc 1 in both
scenarios, so the pair alone never shows head able to PASS — a head broken to always fail would
look identical. That control lives in the suite: test-appliance-defaults.sh:100 ("reachable AND
greeting -> rc 0", through the real preflight_remote_nodes) and :200 ([true,"ok","zmq"]).
Both exist. Worth one sentence in the body pointing at them, so the pair is not read as
self-sufficient.
2. The disclosed missing-curl gap — SHIPPABLE, not blocking.
The reasoning, not just the conclusion:
- The machine-readable half is correct and asserted (
:311,:176). That is the half B1 was
blocking over — no consumer is misled. - The prose is imprecise, not false in B1's way. B1 asserted a positive observation that did
not occur. "cannot reach the node at H:18081" about a probe that could not run is unhelpful and
does point at a fine network path, but it makes no false claim about what was seen. - Reachability is near-nil on the shipping surface.
curlabsent from the appliance image
means the stack is broken in louder ways first. Contrast B1's refused ZMQ port — the single most
likely operator misconfiguration. - It is disclosed twice, here and at
test-appliance-defaults.sh:172-174. - The budget row makes the remedy genuinely expensive: at 450/450 with no raise possible, an
override arm must be bought with a deletion. Wrong trade for a near-unreachable case.
Ship it, and file a follow-up for the whole residual class rather than the one instance:
rpc:timeout, rpc:missing-tool, rpc:unknown and zmq:timeout all land on generic reach
wording. Naming the class is where the line budget for it gets argued.
CI AT THIS HEAD — GREEN, AND THE NEW BLOCK IS SETTLED FROM THE JOB LOG, NOT THE LABEL
Shell tests (shellcheck + pithead suite) — job 101426821054 — completed success. Rollup at
00f7d062: 21 checks, 19 success, 2 skipped (the schedule-gated CVE sweep pair, expected on a
non-digest PR), 0 failure.
The label is not what I read. From the job log:
- The section header is present —
== unit: the node probe reports WHAT it checked, not only that a port answered ==at log line 2773. That string provesrun.shsourced the new block;
a green label alone is equally consistent with it never being collected. - 27 assertions ran inside that block, every one a
✓, including by namea REFUSED ZMQ port reads as refused,a missing curl is named, not folded into unknown,the outer bound firing (124) is a timeout,a live ZMTP peer passes, andeach leg's reason reaches the report. - Zero
✗in all 5,359 log lines.✗is the real failure glyph —bad()at
tests/stack/lib.sh:34-37— and the same grep matches✓4,482 times, so this is a check
that demonstrably can match, not a needle that never fired.
N5 (a figure, not a defect). "The domain went 33 -> 49 assertions" — the 49 is right, the 33 is
wrong; it was 22. Three independent measurements agree on +27, not +16: assert_* call sites
in the file are 22 at c3065010 and 49 at 00f7d062; my enumeration of the new block's assert
calls is 27; and the CI log shows 27 ✓ lines between the new section header and the next. The
conclusion the figure sits under is unaffected — 49/0 is correct and every gap in the reachability
table is closed — but nobody should quote 33 as the pre-#1889 baseline.
NON-BLOCKING FINDINGS
N1 (new, mine — the sharpest residual). Reverting the caller's ZMQ sentence at :393 reddens
NOTHING, and that sentence is half of what B1 was. The fix has two halves: the reason mapping +
override keying (thoroughly covered), and :393's fail_detail changing from the protocol
sentence to the reach sentence. Nothing pins the second — np_row supplies its own fail_detail,
and :84 routes through the zmq:protocol override, which supplies the text regardless of :393.
Put the old sentence back at :393 and a refused ZMQ port renders B1's exact operator-visible
defect with a green suite. Remedy is one line and costs no budget — the test file carries no
budget row: the two-legs case at :242-261 already produces a real refused ZMQ row through the
real node_probe_report, so add an assert_contains on .probes[1].detail for cannot reach
beside the [.probes[].reason] assertion at :259. Not blocking: mutating a caller's English
constant is not a class CI is generally expected to catch, and the enum half is fully covered.
N2 (new, mine). The *) → refused arm at :285 can mis-label a port that DID answer — and the
fix is line-neutral. rc=1 is the dial's own exit 1, but *) also catches everything else,
including 141 if the inner shell takes SIGPIPE writing the greeting to a peer that accepted and
then closed — the published-but-dead container port, i.e. the one scenario the zmq:protocol
sentence exists for. That would report refused with reach wording for a port that answered: B1's
inverse. Stated as REASONED, explicitly not measured — CI-only rule — and the dominant path is
almost certainly rc=0, since the SIGPIPE lands on head rather than the inner bash and there is
no errexit inside the bash -c, so the read pipeline still runs and exits 0. The remedy costs
zero lines — key on the dial's own code instead of the residue:
case "$rc" in
1) NODE_PROBE_REASON="refused" ;;
124) NODE_PROBE_REASON="timeout" ;;
*) NODE_PROBE_REASON="protocol" ;;
esac
Same three arms, same line count, and every non-dial failure falls to protocol — where an
answered-then-broken port belongs — instead of refused. Worth taking because it is free; not
worth blocking on an unmeasured race.
N3. A missing timeout binary on the ZMQ leg reads as refused (127 → *)), where the RPC leg
now names it missing-tool. An enum asymmetry between the legs, in a state where the connect
arm at :338 is broken the same way and always has been. N2's remedy makes it protocol, no
better. Fold into the follow-up from attack 2.
N4. docs/configuration.md's new bullet is accurate on what it names and silent on the rest.
It describes exactly the two classes that DO get bespoke wording and never claims all of them do, so
it is not wrong — but it is the sentence a reader would cite when surprised by a generic message on
a timeout. One clause closes it; it can ride the follow-up.
CHECKS THAT COULD HAVE FAILED AND DID NOT
pitheadis a faithful regeneration. Change bodies of the two slices vs the artifact,
markers and trailing space stripped, sorted: 158 vs 158, byte-identical — with a firing
control: seeding one byte into the artifact side made the comparison report DIFFERS, so the
clean result is not a dead instrument.Closes #1889really does fire. The repo'sdefault_branchisdevelopand the base is
develop. The body's corrected-in-place note is right; no hand-close needed.assert_not_containsexists (tests/stack/lib.sh:41) — the new assertions are not silent
no-ops against a missing helper.- The ZMTP fixtures are real, not green by construction.
NPZ_LIVEgenuinely satisfies the
predicate (ffat 0,7fat 18,03at 20, length ≥ 24);NPZ_HTTPstarts48and genuinely
fails it. So the positive control at:200is a control. - No wallet, hostname, private IP or credential in the diff — only the RFC1918/loopback
literals already used as fixtures in that file.
WHAT I DID NOT DO
CI-ONLY RULE IN FORCE, and I checked it FIRST. fleet.sh claims shows the appliance lane holding
the bench since 2026-09-05T21:34:42Z (#1318 battery) and uptime read load 52.31 — far above the
threshold of 3. So: no tests/stack/run.sh, no make, no shellcheck, no shfmt, no pytest, no node,
no jq experiment, no docker, no KVM, no bench, no worktree. Everything above is git plumbing,
gh, and sed/grep/awk reads.
Specifically NOT measured:
- The 7-mutation battery — I reconciled its reported counts against the assertion set by
reading, which is a weaker instrument than running it. Every row agrees; that is corroboration,
not replication. - The three-socket experiment — not re-run. My finding on scenario A is about its
construction, settled by reading base'sfor portloop, not by re-measuring. - N2's SIGPIPE race — reasoned, explicitly not measured.
make lint-file-budget,make lint-sh,shfmt— relayed from your rc list, not mine. What I did
verify myself is the line counts and thecheck_monotonicsemantics that gate turns on.
Locks/claims held by me: NONE.
VERDICT
PASS at 00f7d06260c171a86222bcd942c9de67b0c76cf8. Both blocking findings are properly fixed;
I attacked the specific arm named in each — the silent-peer ZMQ arm, and tautology/anchor-uniqueness
in the new fixture — and could not break either. The hard constraint holds at 450 → 450, counted
with the gate's own awk 'END { print NR + 0 }'. The three-socket experiment is validly constructed
and its base leg is shown able to give the other answer. The disclosed missing-curl gap is
shippable, and the right follow-up covers the whole class rather than that one instance.
Base is develop, so this is MERGE-READY for the seat's --admin. I am not the author and I do
not merge it. Closes #1889 fires on merge — develop is the repository's default branch — so no
hand-close is needed.
None of N1–N5 blocks. N1 and N2 are cheap enough to be worth taking now if the author wants them
(N2 costs zero lines, which matters under the 450 pin); otherwise they belong in the follow-up
issue alongside the disclosed gap.
Still MERGE-READY at
|
|
Relayed from the ephemeral non-author review of #1937 ( A stale Why it matters now and did not before: Suggested shape: remove One honesty note: the line numbers above are the reviewer's, relayed rather than re-derived. I checked only the one the finding turns on — |
⛔ BLOCKING finding against this branch, re-derived at
|
…g it judged
`preflight_remote_nodes` writes `node-probe.json` and nothing in the tree removes it. Its one
call site sits inside the accept arm of the wizard loop (`12-firstboot-wizard.sh`), so every arm
that hands the form back earlier leaves the previous report standing — and because the host
writes a report on the PASS path too, the survivor is typically a PASSING one. Inert while
nothing reads the file; armed the moment the setup page renders it.
The clear goes where a submission ARRIVES, not on each handback arm: `_spool_clear_error` becomes
`_spool_clear_host_verdict` and voids `node-probe.json` alongside `error.txt` at all four
spool-writing entry points — config submit, rig submit, bare-keep install request, restore. A
per-arm removal would be defeated by the next arm added to the host; this shape is the one the
codebase already uses for `error.txt`, and it stays consistent in the other direction too — the
per-session reset at `12-:198` still preserves both as the reopened page's retry context.
Tested at tier 1, `dashboard/tests/web/test_wizard_probe_staleness.py` (a new file: `test_wizard.py`
is at its 974-line ceiling). Four rows for the accept paths, three controls for the refusal paths —
a bad-JSON submit, an unauthed submit and a mistyped disk confirmation must all LEAVE the report
alone. Negative control run: reverting `_HOST_VERDICT_FILES` to `("error.txt",)` reds exactly the
four accept rows and leaves the three controls green.
docs/dev/appliance-wizard.md states the rule as a spool contract rather than a node-probe detail
(`docs/` is `_shared.paths`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7txeRNDQmj5b4Wsx33mCF
RESOLVED on this branch — the staleness finding above is FIXED at the new head
It stays consistent in the other direction too: the per-session reset at ⛔ THIS MOVED THE HEAD, SO THE RECORDED PASS IS VOIDED AND NEEDS RE-RUNNING. The pass that stands was earned against the previous head and reviewed a tree with this defect in it. What I ran, at the new head
The test, and the control that makes it evidenceNew file, NEGATIVE CONTROL, RUN: reverting The fixture arms with a passing report and asserts it landed before each submit, because a passing survivor is the shape that misleads; a failing one left standing is merely confusing. Coupled with the render half — measured, not assumed
⛔ BUT THE SAME merge-tree RETURNS rc 1: THE TWO BRANCHES CONFLICT ON Proven by me vs relayed, and what I did NOT doPROVEN BY ME at the new head: the four NOT RUN: no KVM leg and no appliance boot — this is a container-side change proven at tier 1, and the host half is unchanged. NOT CHECKED: whether the spool directory's mode permits the unlink on a real appliance. I did not test that, and I am resting on precedent rather than measurement: A gap named: the clear closes the window in which a report outlives its config. It does not make the report atomic with the config — between a submit and the host's next probe write there is a period with no report at all, which the render half must show as nothing rather than as a failure. |
The two legs left running above have landed — all green at
|
| Leg | Figure |
|---|---|
test-frontend (node --test) |
# tests 579, # pass 579, # fail 0 |
test-stack |
pithead tests 3421 passed, 0 failed; data-reset 7; os-update recovery 22; firstboot-journal 17; appliance-hugepages 52 — all 0 failed |
test-integration-selftest |
22 selftest-*.sh scripts summing 937 passed, 0 failed, plus the e2e harness's own selftest at 214 passed, 0 failed |
test-fakes |
31 passed |
Two counts from one run that I reconciled before publishing: the trailing selftest: 214 passed line and the per-script tallies name DIFFERENT populations — the 214 belongs to the harness's own selftest sections (dispatch loop, service_state parsing, rig_lock, …), not to the 22 scripts above it. I also summed the 22 mechanically rather than by hand: my own first arithmetic gave 749 and was wrong.
A sweep for any nonzero failure count anywhere in the log returns 0 lines.
⛔ Standing caveat from the comment above, unchanged: every green here is at cbd573d2, and #1937 merges first by agreement — so the whole battery is re-run at the post-rebase head before this is merged. make lint-sh still judged nothing (OOM at the #1206 cap); the diff has zero shell lines.
* fix(wizard): recover failed reinstall settings (#1954 #1955) * feat(wizard): #1855 #1848 the no-JS form declines Tari by default, and can leave the raffle build_config now writes tari.mode EXPLICITLY on every submit. That is the one place this function departs from its own omit-and-inherit rule, and it departs on purpose: a config with no tari.mode still parses as "local", so an omitted key would quietly merge-mine on a machine whose operator answered No. The parser default is right only for configs written before the question existed. A machine that declines Tari is asked for no payout address — the key is omitted rather than sent empty, since an empty string is a value that would override the documented default. When Tari IS on, an empty address still flows through so the HOST produces the rejection, exactly as it does for Monero. #1848: xvb.enabled is true in config.reference.json, so only the OFF answer is written. Writing True would pin a default the operator never chose to pin. Two existing tests asserted the law this overturns, both now corrected in place rather than deleted, each saying what it used to claim: - test_wizard.py's "tari.mode is local|remote only — there is no Monero-only mode" (line-neutral; that file is at its 974-line ceiling) - test_wizard_form.py's "mode" not in cfg["tari"], which stays narrowly about the remote block it was written to guard Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR (cherry picked from commit e94c009) * feat(wizard): #1855 #1848 the setup page asks whether to merge-mine, and whether to join the raffle The host half (#1905) taught the stack `tari.mode: "off"` and the no-JS form to write it. The page an operator actually sees could not say it: the select was `remoteTari ? "remote" : "local"`, two states for what is now three. A machine holding "off" rendered as "Run the bundled node on this machine" — the wizard misreported the machine to its own operator — and one touch of that control wrote a yes back. There was no way to answer No at all, which is the finding. The question is now "Merge-mine Tari?", No first and default, and the Tari payout address moved under it. It had to move: the field carried `required`, so left in the Payout addresses section it would have blocked submit on a form that never asks the question. It keeps `required` behind a yes — the same bar the Monero address holds. tariAnswer is the migration rule, and it reads the direction the host reads it. Only the literal "off" is a decline; a missing key is `local`, because that is what 28-parse-and-validate-config.sh does with a config written before this question existed. Reading an absent key as "off" would tell an upgraded 1.x machine it had declined merge-mining and write that decline back on submit. note lifted from the FAQ rather than invented. `xvb.enabled` is true in config.reference.json, so the default answer is the state the machine is already in. Both go in a new module: wizard.mjs was 884/889 and wizard.test.mjs 765/765, so the budget decided the design, as it did for #1850 and #1853. wizard.mjs comes out 21 lines lighter. Four existing render probes asserted the old heading and the retired Tari question. Corrected in place and line-neutral (765/765 holds). The three `doesNotMatch(/Payout addresses/)` among them mattered most: left alone they would have passed over a rendered "Payout address" forever. Prose the change falsifies, swept for rather than remembered: README's "every hash merge-mines Tari", appliance.md's "Required, like the Monero one: this stack always merge-mines both coins", and the two dev recipes whose M6 says to paste both addresses into a form that now asks for one. NOT done, and not mine: `./pithead setup` (24-config-wizard.sh) still demands a Tari address and never asks the mode, so the CLI path cannot decline. Filed separately. appliance.md says a remote Tari node saves ~200 GB while the wizard says a local one adds ~170 GB; both predate this change and I did not reconcile them by guessing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR (cherry picked from commit 0393a12) * fix(wizard): #1855 a machine that has never been configured is SERVED the decline Found by re-reading the KVM provision leg #1847 just merged, not by the tests: it posts back whatever `/api/wizard-state` served it, so I went to check what that is for a fresh box. It was `tari.mode: "local"`. The commit before this one gave the page three answers and put No first. That is not the same as No being the answer a new machine arrives holding. The state API builds the page's config as the reference merged under the last attempt, and for a machine with no attempt it fell through to a seed carrying only `local_miner.enabled`. So the reference decided, the reference says `local`, and every new operator would still have opened the wizard on "Yes — run a Tari node on this machine". The whole finding survives a correct component. The seed gains `"tari": {"mode": "off"}` and gets a name, because what it is FOR is now load-bearing: it is the page's answer for a machine that has none, and it is reached only through the `or`. The reference keeps saying `local` and must — it is the default for a config that ALREADY EXISTS, and moving it there would stop merge-mining on every upgraded install. Those two facts look contradictory and are the design; the comment says so where the constant lives. The tests go in a new sibling file for the reason its three siblings give: test_wizard.py is at its 974-line ceiling. They pin the served value rather than the component, and the pair that matters is the migration one — a last attempt with NO tari key at all is still a machine WITH answers, so it gets the reference's `local`, never the new machine's decline. The raffle needs no seed: #1848 is opt-out and the reference already says true. Asserted by moving the reference and watching the served value follow, since a value that happens to equal the reference proves nothing about its source. ⛔ THIS BRANCH MUST NOT MERGE BEFORE #1905. `28-parse-and-validate-config.sh:78-81` on `origin/develop` today is `local|remote` and errors on anything else, so a tree with this and without the host half refuses to provision ANY new machine — including #1847's browser-submit leg, which posts the served config whole. Told the appliance lane; it has recorded the order. PROVEN at this tree: 2582 passed / 0 failed, coverage 97.58% (make test-dashboard); the six wizard modules 132 passed settled from --junitxml; ruff lint+format, file-budget (wizard.py 645/674), pithead parity. A 4-mutation battery on the seed, each proven applied and restored: reverting to the old seed, answering `local`, forcing the seed over an existing attempt, and pinning the raffle each reddened the row that names them, none survived. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR (cherry picked from commit c04d122) * fix(wizard): #1855 #1848 the Tari note names a route that exists, instead of a view that cannot change it The second reviewer's RETURN, taken. Two sentences this branch introduced told the operator they could turn merge-mining on later from the dashboard's Configuration view. They cannot. Re-derived at source rather than taken on the reviewer's word — all three legs hold: - `control_service.py` builds the editable set from EDITABLE_ENV_KEY_PATHS, whose only tari entries are `dashboard.tari_required`, `tari.mem_limit`, `tari.data_dir` and `tari.clearnet_initial_sync`. There is no `tari.mode`, so the field renders greyed. - The host agrees independently: no TARI_MODE in CONTROL_DASHBOARD_EDITABLE_KEYS or the confirm map (`42-control-policy-and-host-checks.sh:61-79`), and the policy is default-deny. - #1905 adds TARI_MODE to `.env` (`33-render-env.sh:470`) and adds it to NEITHER list — I checked both rather than assuming the host half covered it. The capability is real, only the route named was wrong: a set-up-again boot re-runs the wizard seeded from the live config (`12a-setup-again.sh:26-28`), so the select opens on the stored value. Both sentences now say that, following the phrasing `docs/appliance.md:250-254` already uses for the other host-only switches. NOTHING GUARDED THIS — no test asserted either sentence, which is why a false claim about another subsystem's editability shipped. Added one, scoped to TariSection ON PURPOSE: the XvbField sibling says "Changeable later" and that is TRUE (XVB_ENABLED -> xvb.enabled), so a needle swept over the whole form would have pinned the honest row. The needle forbids the PROMISE, not the phrase — the correct copy names the Configuration view too, in order to say it does not carry this switch, so a bare /Configuration view/ needle reddens on the fix as readily as on the defect. Proven: mutation control — reverting the copy to the old sentence reddens exactly this guard, restored by sha256. Frontend suite 594 passed rc 0; lint-js, lint-docs-voice, lint-operator-strings all rc 0, the last run AFTER staging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR (cherry picked from commit 30c8ddf) * fix(wizard): #1923 the "Set up again" screen offers the restore it was hiding A machine booted through the boot menu's "Set up again" entry opened on two choices — Keep it and Set up again — and mentioned restoring from a backup nowhere. The operator most likely to boot that entry is the one whose machine went wrong and who is holding a backup archive, and they were shown the one screen in the product that did not offer it. To reach restore they had to press "Set up again", which reads like "start over and retype everything", and then notice a text link above the form. This is a door, not a feature. Restore-at-setup shipped under #909, and a restore submitted from a set-up-again boot lands today: 12-firstboot-wizard.sh:129 skips the wizard only when `! setup_again_mode`, and restore_apply refuses on size, magic, passphrase, integrity, unsafe paths and an unusable config — never on the machine already having a configuration. Both re-derived at source and posted on the issue. No host change. The whole change is in savedrole.mjs. renderSetup()'s own first line dispatches restoreMode to renderRestore(), so letting the saved-role screen fall through on restoreMode — the same way it already does on setUpAgain — reaches the existing branch with no new route and no line spent in wizard.mjs, which this lane does not touch. Not confirm-gated, deliberately: the restore form is itself a file, usually a passphrase and a second button, which is a more informative wall than a modal. What the operator needs is WHAT it replaces, so the note beside the choice names it — this machine's configuration and secrets, its onion address among them. The new control is a plain button rather than another link-styled one, so it does not add a second sub-24px target to a screen where the design lane has already recorded one (#1868). The over-engineering pass found one real defect in this diff and it is fixed here: restore is the first choice on this screen with a way BACK to it (the restore form's own Back clears restoreMode), so a failed Keep left in `keepError` would have been waiting on the operator's return, reporting an action they had since abandoned. Cleared on that exit, and asserted. Tier 3 (node), as rows in the existing savedrole.test.mjs. One existing row's name said the screen "offers both answers" — this change makes that false, so it is updated with the count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KZuzdtxNX8ae4rnDtBqEqR (cherry picked from commit 84b9462) * wizard: verify remote nodes before staging config Integrate the dashboard intent from PR #1898 at cbd573d while replacing its host TCP-only check with protocol-level checks inside the wizard boundary. Also correct the report roll-up and restore return interaction tracked in #1943, #1946, and #1928. * wizard: close remote probe trust gaps Publish the install trigger only after protocol checks and candidate writes complete, bind accepted node names to the vetted numeric address, require a complete ZMTP greeting and READY exchange, and validate the monerod get_info schema. Addresses the independent security RETURN at 46cd61d without rewriting prior history. * wizard: bind Monero checks to one publisher Resolve each target once, reuse that address across its protocol checks, and require the ZMTP peer to declare PUB or XPUB. Adds adversarial controls for split DNS and a READY-speaking SUB peer after the renewed security RETURN. * wizard: skin the saved-setup restore action Satisfy the merged wizard-shell contract with the existing secondary button skin. The full frontend suite now covers the composed PR1927 interaction. * fix(wizard): reopen installed firstboot failures * Revert "fix(wizard): reopen installed firstboot failures" This reverts commit 2d5c991. * feat(appliance): name coordinators through wizard and apply * fix(appliance): preserve saved addresses and mint long machine names * test(wizard): make hostname coverage work in dashboard image * fix(wizard): expose installed setup failures for retry --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
NON-AUTHOR REVIEW — VERDICT: RETURN at
|
|
Exact-head independent review: PASS
GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used. |
|
Exact-head independent review: PASS
GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used. |
|
Exact-head independent review: PASS
GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used. |
Closes #1889.
What changed
This updates the trusted host-side re-check used by first boot and dashboard endpoint commits. The newer Python wizard probe and its private
node-probe.jsonreport remain the sole report producer; this PR does not add a second reporting path.network.tor_egress_firewall, and reuses one approved address for the protocol checks.get_infodirectly with ambient proxies disabled, configured Digest credentials supplied through curl stdin rather than process argv, a 5-second timeout, and a 1 MiB response cap.status: OK, a supported network, and non-negative JSON-integer heights.GetTipInfoprobe.The probe runs only when remote nodes are configured (or a dashboard commit changes a node endpoint), before provisioning or commit is accepted.
Verification
pitheadparity, ShellCheck, shfmt, diff hygiene, file budget, Markdown, docs voice, operator strings, and topology lints: pass.02c63a330bad761ae9b4e64c8eb0b3f447455422.develop(b99b77002a40db41b227a74130272972889da706) is merged.The exact-head full Linux matrix passed. Shared Gouda/miner hardware was reserved by another active matrix run and was not touched by this PR session.