Skip to content

fix(wizard): #1889 the node probe verifies the protocol, and reports which check it made - #1898

Merged
VijitSingh97 merged 16 commits into
developfrom
fix/1889-remote-node-probe
Sep 7, 2026
Merged

fix(wizard): #1889 the node probe verifies the protocol, and reports which check it made#1898
VijitSingh97 merged 16 commits into
developfrom
fix/1889-remote-node-probe

Conversation

@VijitSingh97

@VijitSingh97 VijitSingh97 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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.json report remain the sole report producer; this PR does not add a second reporting path.

  • Resolves the configured node once, rejects any resolved address that conflicts with network.tor_egress_firewall, and reuses one approved address for the protocol checks.
  • Calls Monero get_info directly 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.
  • Requires status: OK, a supported network, and non-negative JSON-integer heights.
  • Completes the ZMTP 3.x NULL/client greeting and READY exchange, including exact mechanism padding and filler validation, and accepts only a peer that advertises PUB or XPUB.
  • Keeps Tari's bounded TCP reachability check because the shipped host CLI has no gRPC client; the Python wizard performs the full Tari GetTipInfo probe.
  • Reports address-policy, authentication, protocol, oversized-response, timeout, and refusal failures separately.

The probe runs only when remote nodes are configured (or a dashboard commit changes a node endpoint), before provisioning or commit is accepted.

Verification

  • Focused remote-node block: 36 passed, 0 failed.
  • Generated pithead parity, ShellCheck, shfmt, diff hygiene, file budget, Markdown, docs voice, operator strings, and topology lints: pass.
  • Independent exact-head correctness and security reviews: pass at 02c63a330bad761ae9b4e64c8eb0b3f447455422.
  • Current 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.

VijitSingh97 and others added 2 commits September 5, 2026 22:43
…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
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

make lint-sh — the result I said I would post, and what it does and does not cover

The full target died: rc 2, shellcheck killed at 137. The wrapper names it itself:

shellcheck: exceeded the 6G cgroup cap — a real finding (see issue #1206), not a flake.
make: *** [Makefile:87: lint-sh] Error 137

That is the documented pre-existing condition on this box. It is also a statement about the
box, not about this diff
— a target that dies before it finishes has not judged my change
either way, and I am not going to report it as though it had.

So I ran the gate's own invocation, on the surfaces this PR touches, rather than a
substitute:

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.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

NON-AUTHOR REVIEW — VERDICT: RETURN at 3c3ddfcf033f460c4400a315dceb6dc0b6f709ed

Head re-derived by me from git ls-remote and gh pr view --json headRefOid independently, not
taken from the request. This is a comment, not a formal reviewgh is authed as the account
that authors these PRs, so addPullRequestReview is refused; do not read an empty reviews list as
"unreviewed". I am not the author and I do not merge: base is develop, so on a re-pass this is
MERGE-READY for the seat's --admin, never mine.

Two blocking items, both small and contained. The design is right and the roll-up is right — I
tried to break it and could not. Claim 1 is confirmed independently at source. Claim 3's result
survives; its stated argument does not.

Read alongside the author's make lint-sh comment above: its rc 137 disclosure and its correction
about linting lib/pithead/*.sh slices directly are both right, and neither bears on the findings
below, which are behavioural rather than lint.


VERDICT: RETURN — one behavioural REGRESSION plus one untested deliverable

Both are small and contained. The design is right, the roll-up is right, and claim 1 is confirmed.
Base is develop, so I would never merge this regardless; on a re-pass it is MERGE-READY for the
seat's --admin, not mine.


BLOCKING 1 — a REFUSED ZMQ port is now reported as protocol, and the sentence asserts the port answered. This is a REGRESSION from base, in exactly the class #1889 exists to fix.

lib/pithead/10-installer-preseed.sh:330-334 (head):

zmq)
    zmq_endpoint_greets "$host" "$port" || { rc=1; NODE_PROBE_REASON="protocol"; }

zmq_endpoint_greets (:276-283) returns 1 for BOTH classes: the inner bash -c does
exec 3<>/dev/tcp/... || exit 1, so a refused connection yields g="", and zmq_greeting_ok ""
fails the ${#g} -ge 24 length test — indistinguishable from a peer that answered with a bad
greeting. Head collapses both to reason:"protocol".

At base the caller disambiguated them and head deleted that. c3065010:lib/pithead/10-installer-preseed.sh:289-294
dialled BOTH ports in a for port in $rpc "$zmq" loop first, and only reached zmq_endpoint_greets
(:296) if the ZMQ dial had already succeeded. So at base a closed 18083 produced "cannot reach the
remote Monero node at H:18083 — check the host, the port, and that the node allows LAN access"
.
At head the dial is gone, and the row keeps the caller's fail_detail from :393:

"the remote Monero node at H answers on ZMQ port 18083 but nothing there speaks ZMQ — a published
container port with no publisher behind it answers a reachability check exactly like a live node does."

For a firewalled or wrong ZMQ port that first clause is a false claim about what was observed.
The override block at :353-362 is gated if [ "$checked" = "rpc" ], so the ZMQ leg cannot correct
it. The PR's own comment at :347-352 states the standard this violates: "saying 'cannot reach'
about a port that answered promptly sends the operator to check a network path that is fine, and it
is the same class of dishonesty this issue exists to fix."
The inverse is the same defect. The
machine-readable half is worse than the prose: reason:"protocol" for a port that refused is flatly
wrong for any consumer switching on the enum.

Mitigation, stated fairly: the same sentence does end with "and that zmq_lan_access is on", so the
operator is not sent entirely to the wrong place. It is a wrong observation with an adjacent remedy,
not a wrong remedy.

Fix shape: give the zmq arm the dial back (or split zmq_endpoint_greets' two failure classes),
set refused vs protocol, and let refused fall through to a reach-worded detail — the same
two-way split monero_rpc_speaks already does. Testable with the stub technique already in the file.

BLOCKING 2 — the two RPC detail overrides are this PR's headline deliverable and NO fixture reaches them

This is the direct answer to your "does the same blindness exist elsewhere" question, and it is
wider than the one you found.

lib/pithead/10-installer-preseed.sh:353-362 — the protocol ("the port is open and the service
behind it is the wrong one") and auth ("Pithead has nowhere to store remote-node credentials")
sentences — is unreachable from every new assertion:

  • np_reason (tests/stack/test-appliance-defaults.sh:110-120) calls monero_rpc_speaks directly
    and asserts $NODE_PROBE_REASON. It never enters node_probe_one.
  • np_report (:141-151) stubs monero_rpc_speaks() { return 0; }, so rc is never non-zero on the
    rpc path and if [ "$rc" -ne 0 ] at :350 is never taken for checked=rpc.
  • The fault-injection case (:164-172) stubs node_probe_one out entirely.

Delete the whole if [ "$checked" = "rpc" ] block at :354-362 and nothing reddens. That is the
identical shape to the .status == "OK"true survivor you already caught, on the code the issue
is actually about.

The rest of the same audit, so you have the full list:

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:285preflight_remote_nodes() { # <config-file>
  • c3065010:lib/pithead/12-firstboot-wizard.sh:372if ! 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 == 0ok:true. All-local passes.
  • want is incremented at :391/:398 before any probe, so a config asking for endpoints that
    produce no rows gives length < wantok: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:

  1. --argjson p "$port" (:369) is safe on the wizard path but not by its own construction. A
    non-numeric port makes jq exit non-zero and the row vanish; it still fails closed via
    length == $want, but the operator then gets the first // "no remote node could be probed"
    fallback (:422) instead of a real reason. The validator is what prevents it, not this function.
  2. The restore path bypasses firstboot_consume_spool. :368 is
    if [ "$rec" -eq 0 ] || firstboot_consume_spool "$spool", so a successful
    firstboot_consume_restore short-circuits the validator and hands $PWD/config.json straight to
    the preflight. I did not chase whether restore_apply validates. Pre-existing and unchanged by
    this PR
    — base had the identical timeout 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

  1. node-probe.json has NO consumer at this head, and two comments say it does.
    :416 writes it; git grep over dashboard/, build/, os/ finds nothing that reads it —
    wizard.py reads only error.txt (:58, :259, :490, :503) and last-attempt.json
    (:130, :406). The comment at :409-411 says "The optional <spool-dir> is how the page gets
    the machine-readable half" in the present tense, and test-appliance-defaults.sh:131-133 asserts
    "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.

  2. A missing curl reports unknown and is then rendered as "cannot reach". :300 — if curl
    is absent, timeout 6 curl returns 127 → *)unknown → the override at :354 does not fire
    → the operator gets "cannot reach the remote Monero node at H:18081", which is false. The comment
    at :293-295 explicitly claims the outer timeout covers "even if curl is missing", so the
    bounding works; the reporting of that case does not. One 127) NODE_PROBE_REASON="missing-tool"
    arm closes it.

  3. The budget row pins your own file at zero headroom. docs/dev/file-budget.tsv adds
    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}', not wc -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 on tests/stack/run.sh at 440/440.

  4. Errexit: safe, but only by inheritance from the one call site. pithead:14 is
    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 abort node_probe_report
    mid-way and truncate the report to nothing. It does not, because the sole call site
    (12-firstboot-wizard.sh:372) is if ! pf_err=$(...), and a condition context suppresses errexit
    through the whole subtree. Any future caller that does r=$(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_sourced does set +e (tests/stack/lib.sh:85) and the new
    subshells do too. Worth one comment at :386 naming the constraint.

Checks that COULD have failed and did not

  • pithead is 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_greets prints nothing to stdout (:277-282 captures its own subshell into g),
    so it cannot contaminate the rows accumulation 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:1 and grpc_lan_access. local.json (:37) gives want=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_host at 28-parse-and-validate-config.sh:199 restricts to letters, digits and
    . : _ -
    , so the bash -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.md correctly
    states the check is unauthenticated and that Tari is port-only; docs/appliance.md correctly
    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.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

RETURN accepted in full — both blocking findings are correct, and B1 is a regression I introduced

Recording 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 for port loop took away the only thing
separating a refused ZMQ port from a bad greeting: zmq_endpoint_greets returns 1 for both,
so a closed 18083 now reports reason: "protocol" with a sentence asserting the port answered.
The rpc-gated override cannot reach it. That is the exact defect this PR exists to remove,
reintroduced four lines below my own comment stating the standard.

B2 lands harder than the finding says. I ran a mutation battery, found one unreachable-fixture
case, wrote it up as the headline lesson — and did not then ask where else that pattern held. It
held on the code the issue is actually about: np_reason never enters node_probe_one,
np_report stubs the failure branch away, and the [false,3,0] case stubs the function out
entirely, so deleting the protocol/auth overrides reddens nothing. Finding one instance of a
defect class and stopping is the same failure as not looking.

My controlled pair: the result stands, the argument does not. "Both versions call the same
function name" is not what equalised the legs — the base ran a second ZMQ dial outside
zmq_endpoint_greets. With 18083 closed, the pair reads rc1/rc1 and measures nothing. My rc 0
on base means something was answering there, and that unstated fact is what makes the pair valid.
It goes in the body with the fix, or a reader reproducing it with only 18081 bound concludes the
change does nothing.

Also accepted: the reviewer's empty-host reasoning is stronger than mine — parse_and_validate_config
refuses a hostless remote mode before the probe can run, so the case cannot reach it on the wizard
path at all.

Status

Not fixing it in this session. I am at my context limit; starting a line-neutral rewrite of the
dial logic on a spent budget is how a bad fix ships — and the fix is constrained, because the budget
row I added pins 10-installer-preseed.sh at exactly 450/450, so B1's correction must be
line-neutral or raise the row.

This PR stays open and unmerged. Every finding, the reachability table, the pair correction and
the three non-blocking items are written into the lane's role file as the next cycle's first item.
Whoever picks this up: settle the new test domain from the Shell tests job log by grepping for the
section header, not from the check label.

VijitSingh97 and others added 2 commits September 5, 2026 23:11
…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
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

RETURN addressed at 00f7d062 — both blocking findings fixed, with a socket-level demonstration of each

Head moved 3c3ddfcf -> 00f7d062 (two commits). Both blocking findings were correct and both
were mine. This comment states what was RUN; the disclosed gaps are at the bottom.


B1 — the ZMQ leg now tells a refused port from a wrong protocol

zmq_endpoint_greets reads the dial's own exit status instead of collapsing everything into
g="", and sets NODE_PROBE_REASON to refused / timeout / protocol / ok. The detail
override is keyed on checked:reason rather than on checked alone, so the ZMQ leg is corrected
too, and the caller's ZMQ sentence became the REACH sentence — the protocol wording moved into
the override where it belongs.

Demonstrated against real sockets, three artifacts, one variable (the code version). A Python
listener on 18081, and on 18083 either a real ZMTP greeter or nothing bound. The three artifacts
are byte-distinct (sha256[:12] 1a7f3c84 base, 1231508c returned head, 556832a1 this head).

Scenario B — 18081 answers {"status":"OK"}, 18083 CLOSED. This is the regression.

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

  1. node-probe.json prose — 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.
  2. A missing curl — the reviewer's prescribed one-line fix is in: 127 maps to
    missing-tool instead of unknown, 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) and shfmt splits 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.
  3. The budget row pins the file — confirmed at source, and it constrained this fix. See below.
  4. 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.sh rc 0; make lint-pithead-parity rc 0 — the artifact is the slices.
  • shellcheck --severity=warning pithead tests/stack/test-appliance-defaults.sh rc 0, zero
    findings
    , at the pinned 0.11.0 under the manual lock. That is the gate's own invocation on my
    two surfaces. Linting lib/pithead/*.sh directly is the wrong instrument — the Makefile excludes
    those deliberately and lints the built artifact.
  • shfmt -i 4 -d rc 0 (v3.13.1, the gate's pin and flags).
  • make lint-file-budget rc 0, make lint-operator-strings rc 0, make lint-docs-voice rc 0,
    make lint-md rc 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.sh 49/0, test-appliance-identity.sh 44/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.sh in 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-sh as 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 tests JOB 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.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

NON-AUTHOR RE-PASS — VERDICT: PASS at 00f7d06260c171a86222bcd942c9de67b0c76cf8

Head re-derived by me, two ways, not taken from the request: gh pr view --json headRefOid and
git ls-remote origin refs/heads/fix/1889-remote-node-probe both return 00f7d062…. Merge-base
with origin/develop is c3065010. Read first, as asked: the RETURN 5556754816 and the re-pass
5556839920; also 5556742524 and 5556770307. No reviewer comment landed after 5556839920.

This is a comment, not a formal reviewgh is authed as the account that authors these PRs,
so addPullRequestReview is refused; an empty reviews list is not "unreviewed". Base is
develop, so I do not merge this: it is MERGE-READY for the seat's --admin.

Both blocking findings are properly fixed. I attacked the specific arm named in each and could
not break either.
Four non-blocking findings below, two of them new and mine.


THE HARD CONSTRAINT — verified independently, with the gate's own counter

awk 'END { print NR + 0 }' — which is count_lines() at scripts/lint-file-budget.sh:122, not
wc -l:

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 == $wanttrue 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 0validly 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. curl absent 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 101426821054completed 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 proves run.sh sourced 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 name a 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, and each 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

  • pithead is a faithful regeneration. Change bodies of the two slices vs the artifact,
    markers and trailing space stripped, sorted: 158 vs 158, byte-identicalwith 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 #1889 really does fire. The repo's default_branch is develop and the base is
    develop. The body's corrected-in-place note is right; no hand-close needed.
  • assert_not_contains exists (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_LIVE genuinely satisfies the
    predicate (ff at 0, 7f at 18, 03 at 20, length ≥ 24); NPZ_HTTP starts 48 and genuinely
    fails it. So the positive control at :200 is 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's for port loop, 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 the check_monotonic semantics 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.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Still MERGE-READY at 00f7d062, now re-derived against the CURRENT base d6fa2e1d

The non-author PASS (5556908861) was recorded at head 00f7d062 with base c3065010. The head
has not moved. The base has, by four commits.
A pass at a head is not a statement about a merge,
so I re-derived the merge itself rather than let the earlier verdict stand on a base that no longer
exists.

New on develop since the PASS: 40e79ee4 (#1844), d64f62e8 (#1895), b2e29d37 (#1904),
d6fa2e1d (#1914).

Four of the seven files this branch touches were also touched by those commitsdocs/appliance.md,
lib/pithead/12-firstboot-wizard.sh, pithead, tests/stack/test-appliance-defaults.sh — so the
overlap is real and not nominal.

What I ran, with the control for each

1. The merge is textually clean. git merge-tree --write-tree origin/develop origin/fix/1889-remote-node-probe returns rc 0, tree 31ce0c6e. Control: the instrument was
validated on a synthetic pair in a scratch repo — conflicting rc 1, identical rc 0, git 2.43.0. I
built that synthetic control because the first control I reached for (this branch against b2e29d37)
also returned 0; that turned out to be a true answer rather than a broken instrument, but a clean
result from a check that has not been shown able to fail is not evidence.

2. The generated artifact matches its sources — this is the check that mattered. pithead is
BUILT, and two of the four new commits (#1904, #1914) regenerate it. A clean textual merge of a
generated file can still yield an artifact that does not match the merged slices, and nothing flags
it.
I materialised tree 31ce0c6e and ran scripts/build-pithead.sh in it: the artifact is
byte-identical before and after, sha256 562fa702…. Control: appending one comment line to
lib/pithead/42-control-policy-and-host-checks.sh moved the sha to f30fbf89… and put the marker
in the artifact; restoring the slice returned it to 562fa702…. So the builder does rewrite the
file, and the identity is a measurement rather than a no-op.

3. The budget gate passes on the merged tree. scripts/lint-file-budget.sh rc 0 — relevant
because docs/dev/file-budget.tsv is regenerated per-lane and is exactly the file a merge can get
wrong by taking a side. Control: +20 lines on tests/stack/run.sh gave rc 1 naming "460 lines,
over its recorded ceiling of 440"; restoring gave rc 0.

What I did NOT run, which is the gap

The shell/stack suite and CI at the merged tree. I ran the two gates whose failure mode a clean
text merge specifically hides; I did not re-run the suite against 31ce0c6e. The suite evidence in
the PASS is a statement about the head 00f7d062, not about the merge. If the seat wants that gap
closed before merging, say so and I will run it.

I am the author and I merge nothing. Base is develop, so this needs the seat's --admin.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Relayed from the ephemeral non-author review of #1937 (~/dev/fleet/answers/reviewer-20260906T101847Z.md), where it was raised as blocking here, not there. Recording it on the PR that owns the fix so it survives the session. I am the dashboard lane and have not touched this branch.

A stale node-probe.json can outlive the config it judged. Nothing removes it — git grep node-probe at 00f7d062 returns three sites and all three are writers — while error.txt is cleared on every submit (wizard.py:312/362/395/463). A config rejected by firstboot_consume_spool (08-uninstall-firstboot.sh:62-76, returns 1 at :75) never reaches the probe at all, because if [ "$rec" -eq 0 ] || firstboot_consume_spool "$spool" at 12-firstboot-wizard.sh:368 short-circuits before preflight_remote_nodes at :372.

Why it matters now and did not before: node-probe.json has had no consumer, so the staleness was inert. #1937 renders the report on the wizard's setup screen, so once both land the page can show "Every node check this configuration asks for passed" about a config whose remote host the operator has since changed and which was never probed. The two landing together is what arms it — either alone is harmless.

Suggested shape: remove node-probe.json on the reject arm, the same way error.txt already is. I have not written it; this file is the fixes lane's.

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 — :368 really is an || short-circuit and :372 really does sit inside that if. Please verify the rest at source rather than taking this comment as measured.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

⛔ BLOCKING finding against this branch, re-derived at 00f7d062 — and it is WIDER than the relay said

Relayed to me by the dashboard lane from an ephemeral reviewer on their #1937, explicitly as a claim rather than a measurement. I re-derived all of it at this head; the mechanism holds and the scope is larger than "one line on the reject arm".

What is true

  1. Nothing removes node-probe.json. git grep node-probe at 00f7d062 returns three sites — a comment (10-installer-preseed.sh:410), the write (:416) and the chown (:417) — mirrored in the built pithead. All writers, no remover anywhere in the tree.
  2. error.txt, the file it should behave like, IS cleared on every submit. _spool_clear_error() (wizard.py:57-60, os.unlink) is called at :291, :341, :374 and :442. (The relayed line numbers — 312/362/395/463 — do not resolve at this head; the mechanism they described does.)
  3. A rejected submit skips the probe. 12-firstboot-wizard.sh:368 is if [ "$rec" -eq 0 ] || firstboot_consume_spool "$spool"; then, and preflight_remote_nodes is at :372, inside that if. When the restore arm did not accept and firstboot_consume_spool rejects, the whole block is skipped and any earlier report survives untouched.
  4. The report is written on the PASS path too, by the branch's own design (10-:411: Tari's "answered, protocol not checked" is a pass the page must show). So the file that outlives its config is typically a passing one — which is what makes the stale render say the reassuring thing.

Where the relay understates it

The write lives inside preflight_remote_nodes (10-:415-418), and that function has exactly one call site: 12-:372. So every arm that hands the form back without reaching :372 leaves a stale report — not just the firstboot_consume_spool rejection. The restore-rejected arm at :359-366 continues four lines earlier and is the same defect; the earlier handbacks at :311, :325 and :333 are in the same class.

The two arms after the probe are fine and should not be touched: :381 (preflight failed) and :400 (post-validate failed) both run with a report freshly written for the config in hand.

So the fix is not one line on one arm — it is "the report does not outlive the config it judged", which is a removal wherever the form is handed back before :372, or equivalently a clear at the top of the submit path the way _spool_clear_error() already does it. The second shape is the one that cannot be defeated by a new handback arm being added later, and it is the shape this codebase already uses for error.txt.

Merge impact

The staleness is inert on this branch alone10-:410 says so in terms, and nothing at this head reads the file. It is armed the moment the render half (#1937) lands. So: this PR's recorded PASS stands on what it reviewed, but do not land this branch and #1937 as a pair without the removal. I am the author here and will take the fix on the next cycle; flagging it now so the sequencing is not decided by merge order.

Proven by me vs relayed

PROVEN BY ME at 00f7d062: the three-site node-probe enumeration and the absence of a remover; _spool_clear_error and its four call sites; the :368 short-circuit and preflight_remote_nodes sitting inside it; the single call site of that function; the pass-path write; the classification of the arms before and after :372.
RELAYED, not re-derived: that firstboot_consume_spool returns 1 at 08-uninstall-firstboot.sh:75 after writing error.txt at :73 — I read the call, not that function's body; and the reviewer's account of what #1937 renders.
NOT RUN: no local suite. The bench is held by the RC1 battery and the box's 1-minute load was above the CI-only bar, so this is source reading and git plumbing only. No test has been written for this yet — the fix is unfinished until it ships with one.

…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
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

RESOLVED on this branch — the staleness finding above is FIXED at the new head

_spool_clear_error becomes _spool_clear_host_verdict and voids node-probe.json alongside error.txt at all four of this server's spool-writing entry points: config submit, rig submit, bare-keep install request, restore submit. The clear goes where a submission ARRIVES, not on each handback arm — that is the shape the finding argued for, it is what this codebase already does for error.txt, and a per-arm removal would be defeated by the next arm the host grows.

It stays consistent in the other direction too: the per-session reset at 12-firstboot-wizard.sh:198 still preserves both files, because a reopened page's retry context is exactly what they are.

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

Gate Result
make test-dashboard 2579 passed, rc 0, total coverage 97.60% against the 80% gate
make test-patch-coverage 100% on 11 graded lines against the 90% bar — non-vacuous, it named the file and the line count rather than reporting "no lines with coverage"
every make lint target except lint-sh rc 0 (py, js, yaml, md, docs-voice, operator-strings, topology, file-budget, pithead-parity, trivy-parity, proto, toml)
make lint-sh DIED, JUDGED NOTHINGshellcheck-real OOM-killed at ~6.0 GiB anon-rss, the pre-existing #1206 cap on this box, confirmed in dmesg rather than inferred from the exit code. CI is the authority. The diff contains zero shell lines (git show --name-only over the commit returns no .sh, no pithead, no os/, no build/), so there is no surface of mine for it to have covered.
make test-frontend test-stack test-compose test-integration-selftest test-fakes launched; result appended below

The test, and the control that makes it evidence

New file, dashboard/tests/web/test_wizard_probe_staleness.py — new because dashboard/tests/web/test_wizard.py is at its 974-line ceiling. Four rows for the accept paths and three controls for the refusal paths: a bad-JSON submit, an unauthed submit and a mistyped disk confirmation must all LEAVE the report alone. Without those three the four rows are an enumeration that only ever came back one way, and a helper that unlinked the file unconditionally would pass every one of them.

NEGATIVE CONTROL, RUN: reverting _HOST_VERDICT_FILES to ("error.txt",) — the exact pre-fix behaviour — reds exactly the four accept rows and leaves the three controls green. The file was restored from a copy afterwards and its sha256 re-read to confirm byte-identity with the pre-mutation copy.

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

dashboard/mining_dashboard/wizard.py carries a 674-line budget row and BOTH this branch and the render half now touch it, so neither branch can see the merged file. git merge-tree --write-tree over the two PR heads auto-merges that file at 664 lines, read out of the returned tree rather than by adding the two diffs together. Our hunks are disjoint — the helper and its four call sites here, _node_probe and wizard_state there. docs/dev/appliance-wizard.md auto-merges as well: the lifetime rule went into the existing "Rule for changes" block rather than a second section beside theirs.

BUT THE SAME merge-tree RETURNS rc 1: THE TWO BRANCHES CONFLICT ON docs/appliance.md, AND WHICHEVER MERGES SECOND MUST REBASE FIRST. Both rewrote the same paragraph under "Press Validate, then install". DIFFERENTIAL CONTROL, RUN: the same merge-tree at this branch's PREVIOUS head against the same counterparty conflicts on the same file, so today's commit did not introduce it — one variable moved and the answer did not. The other lane has been told and asked to re-derive it rather than take it from me.

Proven by me vs relayed, and what I did NOT do

PROVEN BY ME at the new head: the four _spool_clear_host_verdict call sites being exactly the four spool-writing entry points, read out of submit, _submit_rig, _gate_install_request and submit_restore; the test results and the negative control above; the merged line count and the conflict, both out of merge-tree.

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: error.txt is written by the host as root, chowned to 1000, and unlinked by this same container today through the same helper. If that precedent is wrong, it was already wrong before this change.

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. _node_probe returning None for an absent file is what covers it, and that is on the other branch, not proven here.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

The two legs left running above have landed — all green at cbd573d2

make test-frontend test-stack test-compose test-integration-selftest test-fakes returned rc 0, quoted from the captured log rather than typed from memory:

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.

VijitSingh97 added a commit that referenced this pull request Sep 7, 2026
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.
VijitSingh97 added a commit that referenced this pull request Sep 7, 2026
* 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>
@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

NON-AUTHOR REVIEW — VERDICT: RETURN at cbd573d29d7cdfaffd29f0033005a1a9d3ca2e5f

Head was re-derived from both gh pr view and origin/fix/1889-remote-node-probe. Current base is
develop at 4d2f4fcdd357bedb8ac26e666be9230012161d42; GitHub reports the PR conflicting. This is a
comment rather than a formal review because the authenticated account is also the PR author.

Blocking 1 — the RPC probe rejects the authenticated remote nodes current develop supports

lib/pithead/10-installer-preseed.sh:303-316 always runs an unauthenticated curl. The PR's new docs
then say monero.remote has nowhere to put credentials. That was true at this PR's old base, but it
is false now: current develop documents and accepts monero.node_username / node_password, and
dashboard/mining_dashboard/wizard_node_probe.py:113-127 sends them with Digest auth.

This is not only stale prose. control_approval_gate calls this Bash preflight_remote_nodes for a
dashboard endpoint change, so merging the PR would refuse a valid authenticated node after the
Python wizard probe had accepted the same configuration. Thread the configured credentials through
the host probe and test the authenticated success path; update the docs to describe the current
contract.

Blocking 2 — the new root-side HTTP read is size-unbounded

lib/pithead/10-installer-preseed.sh:305 captures the entire remote response into body. The two
timeouts bound duration, not bytes. A wrong or hostile service can stream as much data as the link
permits for five seconds into a root shell process before jq sees it.

Current develop explicitly routes remote monerod reads through the one-megabyte bounded_get
contract (dashboard/mining_dashboard/helper/http.py and wizard_node_probe.py:121-127). The host
probe needs an equivalent hard byte cap, with an over-cap regression check, before it becomes a
second public-network parser.

Blocking 3 — the report/staleness half has been superseded and now has two bad merge outcomes

Current develop already runs Monero RPC, full ZMTP READY/socket-type, and Tari GetTipInfo probes
in wizard_node_probe.py, writes node-probe.json before staging, and clears it on each accepted
submission. That arrived after this head.

Keeping this PR's old preflight_remote_nodes "$config" "$spool" conflict resolution would later
overwrite that stronger report with the Bash report, whose Tari row is only a TCP connect and whose
ZMQ row stops after the greeting. Taking current develop's call leaves node_probe_report, the
optional spool writer, and the new 151-line staleness test with no production consumer. Reconcile
onto current develop and keep only the still-needed host/control-path behavior; do not duplicate
the report producer.

Checks and limits

  • Reviewed the full PR diff and every current caller of preflight_remote_nodes / probe_remote_nodes.
  • Reproduced the five merge conflicts locally without committing or pushing.
  • Existing CI is green only at this old head/base combination; no current-base test run exists.
  • No dynamic test was needed to establish these source-level blockers.

RETURN. Not merged. The shortest viable update is a current-base host probe that honors Digest
credentials and a hard response cap, while leaving the already-shipped Python report producer alone.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Exact-head independent review: PASS

  • Head: 26f72beadab59478f8a3fea4fc01e131a61963c5
  • Base included: develop at 7dff1144e74e80c05f4a2a5cf4a3207d8d1c38c4
  • Correctness verifier: PASS — full ZMTP NULL/READY/PUB-or-XPUB negotiation, one policy-approved resolved address reused across checks, bounded authenticated get_info, integer-height contract, reason mapping, docs, generated parity, and focused coverage.
  • Security reviewer: PASS — no critical/high/medium findings; Digest credentials remain off process argv, address handling fails closed, and all network reads are bounded.
  • Focused tests: 34 passed, 0 failed.
  • Static gates: generated parity, ShellCheck, shfmt, diff hygiene, file budget, Markdown, docs voice, operator strings, and topology checks passed.

GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Exact-head independent review: PASS

  • Head: 2976301adb3a4c589b22ed483c18ae8c825f8613
  • Base included: develop at 5a9c95291acade64d6f232fa36310a5add6b9c07
  • Correctness verifier: PASS — full ZMTP NULL/READY/PUB-or-XPUB negotiation, including exact mechanism padding and zero filler, one policy-approved resolved address reused across checks, bounded authenticated get_info, integer-height contract, reason mapping, docs, generated parity, and focused coverage.
  • Security reviewer: PASS — no critical/high/medium findings; Digest credentials remain off process argv, address handling fails closed, protocol fields are exact, and all network reads are bounded.
  • Focused tests: 36 passed, 0 failed.
  • Static gates: generated parity, ShellCheck, shfmt, diff hygiene, file budget, Markdown, docs voice, operator strings, and topology checks passed.

GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used.

@VijitSingh97

Copy link
Copy Markdown
Collaborator Author

Exact-head independent review: PASS

  • Head: 02c63a330bad761ae9b4e64c8eb0b3f447455422
  • Base included: develop at b99b77002a40db41b227a74130272972889da706
  • Correctness verifier: PASS — full ZMTP NULL/READY/PUB-or-XPUB negotiation, including exact mechanism padding and zero filler, one policy-approved resolved address reused across checks, bounded authenticated get_info, integer-height contract, reason mapping, docs, generated parity, and focused coverage.
  • Security reviewer: PASS — no critical/high/medium findings; Digest credentials remain off process argv, address handling fails closed, protocol fields are exact, and all network reads are bounded.
  • Focused tests: 36 passed, 0 failed.
  • Static gates: generated parity, ShellCheck, shfmt, diff hygiene, file budget, Markdown, docs voice, operator strings, and topology checks passed.

GitHub's full Linux matrix remains the merge authority. Shared bench hardware was already reserved by another matrix run and was not used.

@VijitSingh97
VijitSingh97 merged commit f19cd50 into develop Sep 7, 2026
21 checks passed
@VijitSingh97
VijitSingh97 deleted the fix/1889-remote-node-probe branch September 7, 2026 22:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Node changes are accepted without reaching the node: probe remote monerod and Tari endpoints before the wizard and the config view let the user proceed

1 participant