Skip to content

Release v1.19.3 to main - #1192

Merged
VijitSingh97 merged 77 commits into
mainfrom
develop
Aug 21, 2026
Merged

Release v1.19.3 to main#1192
VijitSingh97 merged 77 commits into
mainfrom
develop

Conversation

@VijitSingh97

Copy link
Copy Markdown
Collaborator

Straight develop→main release merge for v1.19.3: the tag commit 85eaad1 is develop HEAD, nothing landed after the cut. Real merge, never squash — main stays an ancestor of develop.

🤖 Generated with Claude Code

VijitSingh97 and others added 30 commits August 18, 2026 12:17
Back-merge main into develop after v1.19.2
Bumps the compose group with 1 update: caddy.


Updates `caddy` from 2.11.4 to 2.11.4

---
updated-dependencies:
- dependency-name: caddy
  dependency-version: 2.11.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: compose
...

Signed-off-by: dependabot[bot] <support@github.com>
Updates the requirements on [hypothesis](https://github.com/HypothesisWorks/hypothesis), [ruff](https://github.com/astral-sh/ruff), [pre-commit](https://github.com/pre-commit/pre-commit) and [setuptools](https://github.com/pypa/setuptools) to permit the latest version.

Updates `hypothesis` from 6.165.2 to 6.165.9
- [Release notes](https://github.com/HypothesisWorks/hypothesis/releases)
- [Commits](HypothesisWorks/hypothesis@v6.165.2...v6.165.9)

Updates `ruff` from 0.16.2 to 0.16.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](astral-sh/ruff@0.16.2...0.16.3)

Updates `pre-commit` from 4.6.1 to 4.6.2
- [Release notes](https://github.com/pre-commit/pre-commit/releases)
- [Changelog](https://github.com/pre-commit/pre-commit/blob/main/CHANGELOG.md)
- [Commits](pre-commit/pre-commit@v4.6.1...v4.6.2)

Updates `setuptools` to 84.0.0
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](pypa/setuptools@v83.0.0...v84.0.0)

---
updated-dependencies:
- dependency-name: hypothesis
  dependency-version: 6.165.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: ruff
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: pre-commit
  dependency-version: 4.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python
- dependency-name: setuptools
  dependency-version: 84.0.0
  dependency-type: direct:development
  dependency-group: python
...

Signed-off-by: dependabot[bot] <support@github.com>
Three places claimed releases "up to v1.3.x" are unsigned. They are not: the
first signed release is v1.18.1. cosign.pub was first committed in fe543ac
(2026-08-14), the earliest tag containing it is v1.18.1, and v1.18.0's
published assets carry no pithead.tar.gz.sig while v1.18.1's do. The claim
appears in two operator-facing release-smoke messages, so a reader checking an
unsigned v1.14 release was told signing had been on for eleven minor versions.

The legend-label assertion escaped only parentheses before feeding a label into
a RegExp, which CodeQL flags as incomplete sanitization. There is no untrusted
input here — the labels are literals in the test — but the escape is wrong for
any label carrying another metacharacter, and silently so: with the old escape
a label of "Raffle wins +1" produces a pattern that matches "Raffle wins 1".
Escaping the full set makes the assertion mean what it reads as. Frontend
suite 317/0, identical for today's labels (only parens occur in them).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…compose/compose-d461a4c926

chore(deps): bump caddy from 2.11.4 to 2.11.4 in the compose group
…d/dashboard/python-8ae189fb2d

chore(deps): bump the python group in /build/dashboard with 4 updates
…elease-fact

fix: name the real first signed release (v1.18.1), and escape the whole regex
`release-smoke --upgrade DIR` asserted that DIR's own VERSION had changed. The
#59 upgrade never rewrites the old install in place — that is what makes
rollback possible: it extracts the new release into a fresh pithead-v<new> and
repoints `current`. So the assertion could only pass if the upgrade had
overwritten the previous install, and a correct upgrade was reported as a
failure on the documented final gate of a release.

Every other assertion defect in this repo has been a false green. A false red
on a release gate is arguably worse: the next person either learns to ignore a
red gate or spends an afternoon proving the product was fine.

`upgraded_install_dir` now resolves at assert time and handles both legitimate
inputs — the `current` symlink, resolved after the upgrade moved it, and a
versioned directory, which is unchanged by design and whose answer lives in the
`current` beside it. The v1.19.2 cut escaped the false red only because it was
handed the symlink.

release-smoke gains the sourceable guard pithead-data-reset uses, so the path
arithmetic is unit-testable rather than only exercised post-publish, where a
mistake is already in the field. Three assertions, tier-1 1793/0. The mutation
that goes red is removing the sibling lookup; removing the readlink leaves all
three green, which is recorded in the test as measured rather than assumed —
that call only normalises the path in the operator-facing messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
shellcheck follows a literal source path that names another file in the same
invocation — release-smoke pulls in release.sh, whose `local tool missing=()`
then collides with this file's own scalar `missing` and fails lint-sh. The
same trap as the earlier $REL fix; the tier-1 suite is green either way, so
only `make lint-sh` catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rade-assert

fix(release): resolve the upgraded install at assert time (#1068)
Caddy's built-in HTTP->HTTPS redirect is a catch-all whose target is the Host
header the request carried, so :80 answered `Host: evil.example` with
`308 -> https://evil.example` — measured against a running box, not read off
the config. That is the open redirector #1118 closed in the setup wizard, except
this one is the state a machine spends its life in rather than a one-shot, and
the landing point is the dashboard login: the screen where the operator types
the dashboard password. Reaching it needs a name that resolves to the box, which
is the threat model serving TLS on a LAN already accepts — so hardening, not an
emergency, but permanent rather than one-shot.

The render takes :80 over instead of leaving it to auto_https: emit
`auto_https disable_redirects` and serve one site whose target is fixed to the
address this box answers to. A catch-all rather than a site matching only
$HOST_IP, deliberately — Caddy answers an unmatched host with an empty 200, so
a refusal on :80 reads as a dead machine, and every legitimate request still
lands on the dashboard.

A custom HOST_PORT is untouched: :80 is deliberately left free for the fronting
proxy co-hosting exists for (#740). Plain-HTTP mode has no redirect to steer.

The block is a literal, not $(printf ...) — command substitution strips the
trailing newlines and the next site block landed on the same line as this one's
closing brace, which Caddy refuses. Same trap the $auth line documents from the
other direction.

Proven against the pinned caddy:2.11.4, not just against string matching:
`caddy validate` reports Valid on all four rendered shapes, and a real Caddy
serving the default answers `Host: evil.example` with
`308 -> https://box.lan/setup` — path preserved, host no longer attacker-chosen.

Coverage: five assertions at tier 1, the load-bearing one stated as the defect
rather than the shape of the fix — no redir line may interpolate {host} or
{http.request.host}. Mutations named in the test comment and run.

Closes #1123

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t-redirect

fix(dashboard): stop the :80 redirect trusting the Host header (#1123)
…ad Tor circuit (#1081)

The one-click upgrade was rejected on a healthy bench box with 'could not reach
the GitHub release API over Tor'. Tor was fine and the dial landed; GitHub
answered 403 because its unauthenticated limit is 60 requests an hour PER IP and
a Tor exit is shared with everyone else using it, so the budget had been spent
by strangers. curl -f collapses every non-2xx into one exit code, so the only
thing the operator was told pointed at './pithead doctor' — which correctly
reports Tor egress healthy, because it is. They are sent to a check that will
tell them nothing is wrong, about a box where nothing is wrong.

The remedy is a different one entirely: './pithead restart tor' picks fresh
guards and lands on another exit with its own budget. That worked immediately on
the bench, and nothing in the message pointed at it.

Both release lookups — the pithead one and the RigForge one — now go through a
single gh_release_fetch. Two copies of the same curl call are how one message
could have been fixed and the other left wrong, and a tier-1 assertion counts
the callers so a third copy cannot appear.

NOT done, deliberately: the issue also suggests releasing the 10-minute throttle
on a rate-limit refusal so the operator can act at once. The throttle is claimed
before the dial so a compromised container cannot use failed attempts as an
unthrottled GitHub/Tor beacon, and a rate-limited request DID reach GitHub — so
releasing it restores exactly the beacon the throttle exists to stop. The
message now tells the operator to restart Tor, which is the useful thing to do
during the window they have to wait out anyway.

Coverage: a stubbed curl drives all four outcomes — 200, a 403 naming the rate
limit, a transport failure, and a 500 — and asserts in BOTH directions, so
'always blame the rate limit' fails as loudly as 'never blame it'. Plus the two
counter assertions that keep the callers on the shared function.

Closes #1081

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The release lookup reads the body AND the status now, so a stub that answers
with the bare JSON leaves gh_release_fetch parsing the last line of that JSON as
the HTTP code — every #59 control-upgrade test would have failed on a change
that is about messages. GH_STUB_CODE is there so a later test can drive a real
non-2xx through the control path rather than only through the unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on swallowed it

Adversarial review of my own branch. `rel=$(gh_release_fetch ...)` reads
naturally and is a subshell, so GH_RELEASE_HINT was set inside it and discarded:
every rejection would have carried an EMPTY message. That is the defect this
change exists to remove — a message that tells the operator nothing — put back by
the refactor that removes it, and it would have shipped looking correct.

The fetch now sets GH_RELEASE_JSON and is called as a plain command, so both
globals land in the caller's shell. A tier-1 assertion refuses any caller that
wraps it in a command substitution, because the wrong form is the one that reads
better.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… still have one

The suite caught what the adversarial read did not. Folding the two release
lookups into gh_release_fetch deleted control_upgrade's own `local prefix socks`
derivation — but two later curls in that same function still said "$socks".
Under `set -u` the runner died at its first download: the upgrade result sat at
"running" for ever, one dial in the log instead of two, nothing extracted, and
60 assertions went red together while the seven new unit assertions all passed.

That is the shape worth remembering. The unit tests exercised the new function
and said it worked, and it did. The damage was to the caller the refactor emptied
out, and only the integration block could see it.

The fetch now publishes GH_SOCKS and every dial on that path reads the same
address, so the derivation happens once instead of twice. Two assertions pin it:
three dials use GH_SOCKS, and none refers to a socks variable nobody declares.

Also fixes the subshell assertion, which used `grep -c` with a BRE where \( opens
a group — it errored rather than matching, and reported red on a clean tree. -F.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nt tripped my own guard

Two of the four remaining failures, both in checks rather than code.

The worker-upgrade stub answered */releases/latest with bare JSON while already
emitting 202 and 200 for the endpoints the product reads with -w. The release
lookup now reads a status too, so that branch owed one — three RigForge
assertions were failing on a stub that predated the change.

And the subshell guard was matching the sentence in pithead explaining the very
mistake it exists to prevent: the comment quoted the wrong form literally, so
grep counted it. The guard was right; the prose was the problem. Reworded rather
than weakening the assertion to skip comments — a guard that ignores comment
lines is a guard someone can disable with a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ator's message

The last failing assertion was a real finding, not a stub artefact. When the
response carries no status line, `code=${out##*$'\n'}` has no newline to strip
and `code` becomes the ENTIRE BODY — which then went straight into an
operator-facing string: 'the GitHub release API answered HTTP {"message":"Not
Found"}'. Nonsense to read, and a way for a remote body to land verbatim in the
dashboard.

A status that is not three digits is now its own case with its own message. The
body never reaches the operator.

The junk stub gets its status line too — it is simulating a 200 whose body has no
usable tag, which is exactly what the assertion is about, and without the status
it was simulating something the product can no longer receive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion

I added the three-digit guard in response to a failing test and left the guard
itself uncovered — the assertion that went green afterwards only proved the stub
now sends a status, not that the product handles a response without one.

GH_STUB_NOCODE makes the stub answer the way that produced the defect, and the
assertion is stated as the defect: the hint must not quote the response body.
Mutation run — remove the guard and it goes red (1815/1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught what I did not: I ran shellcheck on pithead and not on the test file,
so two `source "$STACK"` blocks went in without the disable directive every
other block in this file carries. `make lint-sh` is the surface that sees this
and the suite is not — the lesson the last session already wrote down, and I
still only ran half of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…telimit

fix(upgrade): tell the operator a spent GitHub rate limit is not a dead Tor circuit (#1081)
The repo already had a pin watcher. It lived on `develop-v2`, where a
`schedule:` trigger can never fire — GitHub runs schedules from the default
branch — so it ran exactly zero times and the two pins it covered were never
checked once. Its own header comment knew ("until the appliance tree merges
there, the guard below makes every run a quiet no-op"), and the appliance tree
is never going to merge to `develop`.

So: on `develop`, widened to the pins #1128 is actually about, and the logic in
a script rather than inline YAML so `make lint-sh` shellchecks it and tier 1 can
drive it.

REPORT-ONLY, deliberately. RigForge's xmrig-bump.yml opens a build-verified PR
and that is right there — XMRig is a drop-in binary and the build gate proves
the candidate. A Tari or monerod minor is a data migration to schedule; #1129
carries three one-time migrations and a one-way wallet-DB change. One shape,
two outputs.

The comparison is the whole product, not the fetch. Our pins do not spell
versions the way upstream tags them — `caddy:2.11.4` vs `v2.11.4`,
`minotari_node:v5.3.1-mainnet` vs `v5.6.0` — and a plain string compare reports
two components stale every week for ever. A watcher that cries wolf weekly gets
muted, which is exactly as useful as one that never runs.

Unreachable is not current: every failed lookup is counted, the run exits
non-zero, the report names what could not be checked, and the "last fully
successful check" line is only written on a clean run. A watcher that has
silently stopped otherwise looks identical to one with nothing to report.
ponytail-review on my own diff: pinned_digest() and image_ref() existed only
for resolve_digest, which came out when the registry lookup did. Dead code plus
three self-test rows driving it, and two comments that described the old shape.
-22 lines, self-test still 10/10.
Adversarial pass on my own PR. On a failed run the report body is replaced by
the failure notice, which took the `Last fully successful check` line with it —
and that date is the only thing separating "failed once this morning" from
"has been dead for six weeks". A watcher whose own silence is invisible is the
exact defect this watcher exists to catch.

Carries the previous date forward, marked as not-this-run. Also drops a stray
`(return 0 2>/dev/null) || true` from the script that did nothing.
…o notice

CLAUDE.md's bar is that a change ships with its docs. The pins section
described bumping a component and said nothing about learning a bump exists,
which is the whole of #1128. Names the watcher, why it reports instead of
bumping, and that an unchecked pin is not a current one.
Watch upstream component currency, weekly and report-only — the previous watcher had never run (#1128)
The three digest assertions stopped at `tag@sha256:`, so they passed on the
half-done bump that matters: move the tag here and in the compose file, leave
the digest, and the stack keeps pulling the old image while the compose file,
this test, the release notes and the docs all announce the new version. In a
`tag@digest` reference the digest is authoritative and the tag is decoration —
which is the stated purpose of the pin.

Proven side by side. Swap caddy's digest and leave its tag:

  NEW form: ✗ caddy pinned by digest: missing [caddy:2.11.4@sha256:df7f1c2...]
  OLD form: ✓ PASSES on the mutated digest — could not fail

The console wallet had NO digest assertion at all, and could not have one where
the other three live: $RENDERED is built with local_node,local_tari and
tari-wallet is profiles: ["tari_payout_confirm"], so an expect_present there
passes and fails identically. It goes with the profile-enabled JSON, for the
same reason the wallet healthcheck assertion already does.

And the two Tari images are one component bumped together, so a tag that moves
on one and not the other is a silent split-brain. Nothing compared them —
`scripts/release.sh pin tari` reads the node only (#1138), so a wallet left
behind is invisible in the release notes too.

Three mutations run, each killing its target: digest-only swap on caddy,
tag-only move on the console wallet (kills both new assertions), digest-only
swap on the console wallet.
ponytail-review on my own diff: six lines of grep|sed|sort|wc plus an if/else
against raw YAML, where the profile-enabled JSON render is already in hand and
jq_assert does it in one line — parsing instead of grepping. Mutations re-run
after the change rather than assumed: the tag-only move still kills it.
Assert WHICH digest, not that a digest is present — and cover the console wallet at all (#1137)
VijitSingh97 and others added 29 commits August 21, 2026 00:54
…either (#1137)

#1137's parts 1 and 2 landed in PR #1147 — the three assertions spell the whole
64-hex digest now, and the console wallet got a jq_assert on the profile-enabled
render plus a same-tag lockstep check. This is the one residual instance of the
same defect that pass did not close.

tecnativa/docker-socket-proxy is pinned on TWO services, docker-proxy and
docker-control, and expect_present is a `grep -q`. It matches either line, so
bumping one proxy and leaving the other was green — and the two would then run
different socket-proxy builds, which is precisely the split the separate-proxy
design exists to prevent (one is read-only, the other is the start/stop control
path).

expect_min already existed and takes a minimum count; the literal renders exactly
twice, so the count is measured rather than aspirational.

Mutation, run: move ONLY docker-control (docker-compose.yml:865) to
v0.4.3@sha256:0000…0007 and leave docker-proxy at :813.
  before: ✓ tecnativa socket-proxy pinned by digest
  after:  ✗ tecnativa socket-proxy pinned by digest (both proxies): expected >= 2, got 1

Does NOT close #1137 — part 3, resolving each pinned tag against its registry,
is the registry half and needs a client with two token flows. scripts/pin-watch.sh
already says so in the file.
#1067)

The e2e leaves the box-global control units pointing at the harness checkout, or
deletes them. Either way the live dashboard's config edits and one-click upgrades
queue into a spool nothing watches — enabled, active, and silent — while every
other signal in the restore proof still reads healthy. It stranded the bench
during the v1.19.2 pre-cut run and was found only by looking.

The design posted on the issue does not work, and the way it fails is the exact
defect it was written to fix. `pithead` cd's to the directory of the binary you
invoke (SCRIPT_DIR, pithead:100), so "run the BRANCH's pithead doctor with cwd =
the live install dir" evaluates E2E_DIR: check_control_units compares the units'
ExecStart against $PWD, and "pithead-e2e" does not match the versioned-dir regex
that would have made doctor bail out. On a STRANDED box the units name E2E_DIR,
so it prints "Control runner units target this install." — the OK verdict,
precisely when the box is broken — and FAILs on a correctly restored one. The
matching repair line would have re-installed the units pointing at the harness.

So the verdict comes from RESTORE_DIR's OWN doctor, run from RESTORE_DIR, and
there is no repair line: restore_all's existing `cd "$RESTORE_DIR" && ./pithead
apply -y` already converges the units, because v1.19.2's apply calls
provision_control_runner before its no-change return. That is tier-1 asserted
("a no-change apply still converges the control units, and recreates nothing"),
so this leans on a guarded behaviour rather than an assumed one.

The oracle is a classifier in lib.sh with a **no-check** arm, not a grep. A
pithead predating v1.19.2, or a box without systemd, prints no control-channel
section at all — without that arm "never looked" reads exactly like "looked and
it was fine". Six selftest assertions bind it to doctor's literal verdicts,
including the two arms a lazy version collapses: deleted units, and no section.

Also here, same function:

- #1067: every borrowing run minted a timestamped .e2e-orig.<stamp> and nothing
  removed it. The backup is now pruned, but only after `cmp -s` proves the bytes
  are back, in the SAME remote call so a dropped ssh cannot land between the
  proof and the delete. Deliberately NOT gated on miner_reload — that ends
  `|| true; return 0` and cannot fail, which is also why the warn arm it used to
  guard was unreachable.

- A third member of the family, found while checking the loaner: borrow_miner
  backs up whatever is there, and its repoint has an `if any(... contains($b))`
  arm written for a rig that legitimately keeps a bench pool. That arm also
  absorbs a previous run that never restored — the backup then records the
  BORROWED config as the original and every later run restores to it. Live on a
  loaner right now: 32 backups, 3 distinct contents, and the two most recent
  already carry a bench pool. It warns loudly before the backup is taken.

- tests/integration/run.sh's hardening phase asserted only that something called
  pithead-control.path was enabled. On a bench that also hosts a live stack that
  is true before the phase runs — the LIVE install's units satisfy it — so it
  stayed green even if our apply installed nothing. It is bound to the ExecStart
  now.
…nt list itself (#1138)

Adversarial review, confirmed by execution: the drift guard as first written
could only detect an EMPTY pin. "Is $pv present in the file it came from" is a
TAUTOLOGY for every arm — pin() extracts a substring of that same file, so any
non-empty answer is present by construction.

That is this repo's signature defect wearing a new hat: the label named the drift
it guards against and the code could not see it. The reviewer demonstrated it by
re-pointing the tari-wallet arm at the NODE's regex — a copy-paste slip, or a
later edit tidying two near-identical lines. Every row still printed ok, and the
release notes would have named the node twice while a wallet-only move again read
as unchanged. Exactly #1138, reintroduced inside #1138's own fix.

Each row now names the identifier its pin must contain, so the arms are bound to
each other as well as to the file.

Second finding, also confirmed: #1138's actual DELIVERABLE — write_manifest
naming both images — had zero coverage. The drift guard tests pin(), a different
function; deleting either printf left it green while the notes regressed. The
manifest fixture was already rendered two assertions above, so asserting the
artefact an operator actually reads costs two lines.
…ings

Adversarial review found seven problems; five were confirmed on execution and
are fixed here. The worst is that the first version repeated the defect it fixes.

1. BLOCKING — the proof ran downstream of its own repair. verify_restore_proof is
   called only after restore_all's `cd "$RESTORE_DIR" && ./pithead apply -y`, and
   that apply converges the units. So on the ordinary #1085 path the strand is
   already gone by the time the check looks: it could not fail for the exact
   incident it was written for. The reviewer's refinement made it sharper —
   provision_control_runner short-circuits with `grep -qsF` when the units already
   match, so the repair fires precisely when the check would otherwise catch
   something.
   Fixed by recording CONTROL_VERDICT_BEFORE in restore_all BEFORE that apply.
   The run log now says whether THIS run stranded the box, and the post-restore
   arm is honestly labelled as what it is: proof the box was left working.

2. `disabled` was treated as a pass. It is the one state where a strand SURVIVES
   the restore — apply leaves the units alone when control is off, so units the
   e2e installed sit there naming the harness checkout. It now looks for exactly
   that leftover.

3. doctor has a fourth outcome the classifier folded into `stranded`: "This is
   not the live install", an INFO that means "I declined to grade this", not a
   fault. On a box whose `current` names a different version dir that produced a
   hard failure on a false claim. New `not-live` arm plus its assertion.

4. `on-target` proved the ExecStart text, not that the channel is live.
   provision_control_runner's `systemctl enable --now` is warn-only, so apply
   returns 0 with correctly-named units that never fire. The probe now checks
   is-enabled too — the fault this PR describes is "enabled, active and silent",
   and half of that was unasserted.

5. borrow_miner's contamination probe failed OPEN on every error path
   (`2>/dev/null || echo 0`), and one pool entry without `.url` made jq raise for
   the whole expression. The failure condition correlates with the fault: a config
   left half-written by a run that died before restoring is exactly the input that
   made it report clean. It is a three-way answer now — present, absent, or
   could-not-tell — and could-not-tell says so.

Also: the control verdict gets its OWN failure flag. Sharing RESTORE_PROOF_FAILED
routed it into the #971 credential message, whose remediation ("re-bake from disk:
docker compose up -d") does nothing for a systemd unit. And the miner-backup
failure message no longer promises a backup that an ssh drop after the prune may
already have removed.

Refuted, and not changed: that the shared prc produces a false failure on healthy
boxes (on a pre-v1.19.2 install the run really does strand the box, because that
apply cannot converge — exit 1 is right), and a label nit on the tor-base row.

selftest: 208 passed, 0 failed.
…wallet

fix(release): name both Tari images in the ingredient list, and guard the compose pins (#1138, #1137)
…ol-units

fix(e2e): prove the live stack's control channel survives a run (#1085, #1067)
tests/stack/run.sh is ~8000 lines and every fixture gets its own `mktemp -d`
except one: the tor entrypoint renders to /tmp/torrc and the suite's stub tor
cats that same host-global path. Two concurrent runs therefore race on one file
and redden the hidden-service assertions — a false RED whose natural remedy is
"re-run until green", which is the habit this repo has spent months removing.

TORRC_OUT is the seam, alongside the TORRC_TEMPLATE seam already there for the
input side. The container default MUST stay /tmp/torrc and does: tier-3
assertions in tests/integration/run.sh read that path INSIDE the running
container, so a seam that moved the default would silently redden three e2e
checks.

The proof is not a concurrency race test, which would be a flake generator. The
stub now cats the SANDBOX path instead of /tmp/torrc, so if the entrypoint
ignored the seam it would print nothing and every assertion below goes red —
whereas catting /tmp/torrc would happily keep passing off a stale file from an
earlier run, which is the vacuous version of this check. Two assertions pin the
default in place and bar any other bare use.
test(tor): sandbox the suite's one host-global fixture path (#1104)
…opped being the site block

Found by running the mandated pre-release e2e for v1.19.3: it FAILED, 8 times,
on a healthy box.

  ✗ Caddyfile uses correct scheme

The assertion is `assert_contains "$(rx 'head -n1 Caddyfile')" "$scheme"`. On
2026-08-18, #1123 stopped Caddy's :80 redirect trusting the Host header by
emitting a global options block at the top of the file, and in HTTPS mode an
`http:// {` redirect block ABOVE the dashboard's own site block. So the rendered
file now begins:

  {
      auto_https disable_redirects
  }

  http:// {
      redir https://<host>{uri} 308
  }

  https://<host> {

Line 1 is `{`. The assertion has been reading a brace and looking for a scheme in
it ever since — a FALSE RED on a correctly configured stack, on the gate that is
mandated before every release cut. That is #1082's family: an always-red
assertion that trains the operator to accept red on the one check standing
between a bad release and the world.

The naive repair is also wrong. "The first scheme in the file" is `http://` — the
redirect block — on a box that is correctly serving HTTPS, so it would fail
secure=true boxes for a new reason.

The dashboard's block is the one whose address carries a HOST after the scheme.
The bare redirect is `http:// {`, a space where the host would be, so `[^ ]`
separates them. Verified against the real rendered Caddyfile on the bench:
`grep -cE '^https://[^ ]'` -> 1, `grep -cE '^http://[^ ]'` -> 0.
…ssert

fix(e2e): the Caddyfile scheme assertion reads line 1, which #1123 stopped being the site block
…#1178)

borrow_miner backed up whatever xmrig config it found and called that "the
original". If a previous run repointed the rig and never restored (crash,
--keep, a failed teardown), the backup enshrined the borrowed config as
original and every later run restored back to it — self-perpetuating, with
only a warn-only probe that could not tell "operator's own bench pool" from
"our own unrestored leftover" apart.

Tag the pool the harness injects with "rig-id": "pithead-e2e" (a documented
per-pool xmrig key xmrig ignores for pool selection). That makes the harness's
own leftovers unambiguous:

- borrow_miner now counts tagged pools before minting the backup; if any
  exist, strips them first (one jq pass, loud warn) so the backup records the
  true pre-borrow state. An untagged pool naming the bench is left exactly as
  before (warn-only) — it could be the operator's own, or contamination from
  before this tag existed, and the tag can't tell those apart retroactively.
- restore_all strips any tagged pool that somehow survives the cp/cmp
  restore, belt-and-braces, logging if it was ever not a no-op.

Verified against local fixtures (clean / tagged-leftover / untagged-bench /
both) with plain jq — no bench needed:
  clean:            tagged count 0, bench count 0
  tagged-leftover:  tagged count 1 -> strip -> tagged count 0
  untagged-bench:   tagged count 0, bench count 1 (preserved, warn fires)
  both:             tagged count 1 -> strip -> tagged count 0, bench count 1
                     remains (the untagged sibling survives, warn fires)

Mutation: misspelled the tag in the strip select (pithead-e2e -> pithead-e2)
and reran against tagged-leftover.json — RED, as required: the strip became a
no-op and the tagged pool count stayed 1 instead of dropping to 0, i.e. the
fixture check would have caught it. Restored the correct spelling before
committing.

make lint-sh and `bash -n tests/integration/e2e.sh` both pass. The real e2e
was deliberately not run (needs the bench) — the pre-cut e2e run exercises
this live. The loaner's existing bench entry is untagged pre-existing
contamination this tag cannot retroactively identify; that stays a manual
one-time repair, not part of this diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rows; #1144 stays open)

The three stale non-Tari rows in the weekly pin report. Tari stays at v5.3.1
deliberately: v5.6.0 migrates the wallet DB one-way and is scheduled work with
a bench gate, not a patch-day bump.

- monero v0.18.5.1: tarball sha256 from getmonero.org hashes.txt (also printed
  in the GitHub release body). Patch release; no daemon flag changes.
- p2pool v4.18: tarball sha256 from the GPG-signed sha256sums.txt.asc release
  asset, agreeing with the GitHub API asset digest. No --merge-mine or gRPC
  changes across v4.17-v4.18. One behaviour change audited against v4.18
  source: the SOCKS5 exemption now covers private addresses on the node RPC
  leg (json_rpc_request.cpp:250), so the #278 monerod socat bridge becomes
  belt-and-braces; the Tari merge-mine leg still exempts loopback only
  (tcp_server.cpp:425), so its bridge stays load-bearing. Both bridges kept;
  comments now state the v4.18 reality. The out-peers=32 / priority-node
  recommendations were re-verified identical in the v4.18 README before the
  comment respell.
- socket-proxy v0.5.0 on both proxies, pinned to the Docker Hub index digest.
  Upstream diff audited (Dockerfile, haproxy.cfg, entrypoint): additive only —
  the new pause/unpause routes sit behind ALLOW_PAUSE/ALLOW_UNPAUSE opt-ins we
  do not set, BIND_CONFIG is compute-if-unset, HAProxy base 3.2.4->3.4.2. The
  docker-control hardening assertion now also denies ALLOW_PAUSE/ALLOW_UNPAUSE
  so the next new endpoint class of this shape is visible to the gate.

RUN: tests/stack/run.sh full suite (1826 passed, 0 failed); test_compose.sh
standalone (85 passed). Mutation run and named: ALLOW_PAUSE=1 added to
docker-control env -> exactly 'docker-control is start/stop only' went red;
restored. lint-sh + lint-docs-voice clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#1137 part 3)

pin-watch.sh already answers whether a pinned VERSION is behind upstream's latest
release, but says outright that it does not check whether a pinned `tag@sha256:...`
still has a digest matching that tag. This adds the digest-level check: pins come
straight from one grep over docker-compose.yml (self-maintaining, no hand-kept
component list), each tag is resolved via `docker buildx imagetools inspect
--format {{println .Manifest.Digest}}` with shape-checking and a 3x/2s retry so a
registry hiccup can't fake a red, and a lookup that never resolves is counted and
named as UNCHECKED rather than silently read as current.

Wired in three places:
- scripts/release.sh preflight() now runs it unconditionally (not DRY_RUN-guarded,
  same as the existing verifier-image check right above it) and dies with the
  operator-facing reason if a pin doesn't match.
- tests/stack/run.sh runs `resolve-pins.sh --self-test` alongside the existing
  pin-watch self-test.
- .github/workflows/pin-watch.yml's matrix job runs it as a final step on both the
  product and appliance lanes, so a drifted digest reds the weekly run on either
  tree.

Mutation run (THE #1137 case): changed run_check()'s comparison from
`[ "$live" = "$pinned" ]` to the tautology `[ "$live" = "$live" ]` — i.e. a tag
that resolves to a NEW digest while the file still pins the old one, the exact
half-done bump #1137 describes, on an otherwise well-formed file. Self-test went
red as required:

  self-test FAIL: a moved tag with the old digest left behind is caught (got [0], want [1])
  self-test FAIL: the mismatch is named, not swallowed (got [0], want [1])
  EXIT=1

Reverted the mutation; `bash scripts/resolve-pins.sh --self-test` is green again
(9/9) and `bash tests/stack/run.sh` passed in full (1827 passed, 0 failed).

Verified: bash -n on all four touched files, `make lint-sh` (shellcheck +
shfmt, clean), `make lint-operator-strings` (unaffected — that linter only
scans the `pithead` binary and the dashboard frontend, not scripts/*.sh).

Not run: the live `bash scripts/resolve-pins.sh` against the real registries.
Docker Desktop is installed on this machine but its daemon startup is stuck on
an interactive "Docker Desktop requires privileged access" macOS admin-password
prompt that this headless session cannot answer (confirmed via `ps` — an
osascript privileged-helper process waiting on GUI input). --self-test needs no
docker daemon (docker is stubbed as a shell function) and ran for real; the live
network path did not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
build: bump monero v0.18.5.1, p2pool v4.18, socket-proxy v0.5.0
Found attacking my own branch: if the extraction grep ever stops matching
docker-compose.yml (format drift, quoting change), run_check loops over
nothing, both counters stay 0, and the script printed the all-clear with
rc 0 — green while checking nothing, the exact genus this script exists
to kill. An empty extraction now fails loudly, with its own self-test
case (11 cases now; deleting the guard turns exactly that case red).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment (#1094)

test_editable_keys_have_no_intra_repo_drift and test_confirm_keys_have_no_intra_repo_drift
compare pithead's CONTROL_DASHBOARD_EDITABLE_KEYS/CONFIRM_KEYS against control_service's
EDITABLE_ENV_KEY_PATHS/CONFIRM_ENV_KEY_PATHS — that catches the two copies disagreeing, never a
key added to BOTH at once. Adds test_perimeter_env_keys_never_committable_from_either_copy: a
hardcoded NEVER_COMMITTABLE_ENV_KEYS deny-list (dashboard auth, onion exposure, bind/host, the
control channel itself, Tor egress, wallet/node credentials) checked against each of the four
allowlists independently, so agreement between the copies no longer implies safety.

Mutation proof — added DASHBOARD_AUTH_HASH_B64 to both pithead's CONTROL_DASHBOARD_EDITABLE_KEYS
and control_service.EDITABLE_ENV_KEY_PATHS (same mutation the issue names), ran
`pytest tests/service/test_control_service.py -k "perimeter or drift"`:

  before mutation: 5 passed
  after mutation:  test_perimeter_env_keys_never_committable_from_either_copy FAILED
                   ("DASHBOARD_AUTH_HASH_B64 in pithead's CONTROL_DASHBOARD_EDITABLE_KEYS")
                   the two pre-existing drift tests stayed GREEN, reproducing the blind spot
  after revert:    5 passed again

make test-dashboard: 1722 passed, 97% coverage. make lint: clean.

#1094 is a sub-item of #1069, which stays open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test(e2e): tag the injected pool so borrow_miner can tell its own entry from the operator's
release: resolve every third-party image pin against its registry before a cut (#1137 part 3)
A perimeter key whose spelling no longer exists guards nothing — the
renamed real key could join every allowlist while the deny-list stays
green. Each entry now must appear in the pithead text at all.

Mutation run: TOR_EGRESS_FIREWALL respelled TOR_EGRESS_FW in the deny
list -> red ("dead perimeter entry"); restored -> green. All 16 current
entries verified present (3-14 occurrences each).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-test

test(control): pin the security perimeter itself, not just allowlist copy-agreement
scan_pithead only matched log/warn/error/info/echo, so the doctor's
dr_ok/dr_warn/dr_fail/dr_info reporters and the control runner's
_upg_fail result strings could carry an issue number even though both
are operator-facing (doctor output on screen, _upg_* errors rendered
in the dashboard's upgrade panel). Switched scan_pithead from $NARROW
to the already-defined $WIDE call-shape list, which scan_pithead_docs
was already using.

Reworded the 11 call sites the widened rule now catches (11, not the
issue's ~12 — one cosign #NNN had already been cleaned up by
unrelated work, and one new #424 site had appeared since triage): all
but one just drop the parenthetical issue number since the sentence
already plainly states the situation and remedy; the clearnet-sync
WARN gets a $DOCS_URL pointer to docs/privacy.md since that's the one
message where a reader benefits from the fuller threat-model writeup.

Added two self-test cases proving scan_pithead now flags a dr_* and
an _upg_* call carrying #NNN.

Mutation check: planted `dr_ok "... (#1026 MUTATION TEST)."` at
pithead:1016 and confirmed `bash scripts/lint-operator-strings.sh`
went red (reported exactly that line); removed it and confirmed green
again. Not part of the committed diff.

Ran: make lint-sh lint-py lint-js lint-yaml lint-md lint-docs-voice
lint-operator-strings lint-toml (all clean; lint-proto not run, needs
buf/network). bash tests/stack/run.sh: "pithead tests: 1826 passed, 0
failed".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…strings

lint: operator-strings rule covers dr_*/upg_* call shapes; clean the 11 sites it catches
The Advanced view's "Your Stack" grid row is as tall as its tallest
card, so shorter neighbours trailed 300-500px of blank space (#991).
Two low-risk shapes from the issue, scoped for a patch release:

- Collapse the P2Pool Earnings card's subtitle + disclaimer prose
  (the single biggest block in the tallest card) into a
  collapsed-by-default <details>, same pattern as .egress-details.
  Copy is unchanged, just relocated.
- Reorder the section's cards tall-with-tall / short-with-short
  (XvBStats+EarningsCard, NodeStats+ExpectedVsActualCard,
  TariCard+CadenceCard) so the auto-fit grid's rows pair similarly
  sized neighbours instead of stranding a short card under a tall one.

Measured via the dashboard visual harness (build_state fixture, no
docker): the section height drops from 973px to 704px at desktop
width (~27% shorter) with earnings.available true end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…space

dashboard: stop the Your Stack grid trailing half a section of blank space
VERSION + dashboard pyproject to 1.19.3 in lockstep, uv.lock regenerated,
CHANGELOG rolled with the full section for this cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The anchor line added on top of PR #1186 was never run through lint-py;
release.sh's stage-2 gate caught it on the cut. Formatter output only —
git diff shows a single assert message wrapped, nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
style: ruff-format the perimeter anchor assert
@VijitSingh97
VijitSingh97 merged commit 4eea9a3 into main Aug 21, 2026
35 checks passed
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.

1 participant