From 3721911de141e50963b69d0f354ac563bb700517 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 13 Jul 2026 07:43:46 -0500 Subject: [PATCH 1/4] release: v1.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump VERSION 1.4.0 -> 1.5.0, move CHANGELOG [Unreleased] -> [1.5.0] (2026-07-13), keep pyproject.toml + uv.lock in lockstep (the #44 drift-guard). v1.5 — the fleet release: consumes RigForge v1.8.0's producer contract (enriched feed + control API). Worker Inspect (#185), energy & profit calculator (#260), enriched-feed chips (#235), the RigForge contract test (#209), Telegram two-way control (#338), on-chain payout confirmation (#381/#462), extra alert sinks (#380), host-side secret merge (#440), plus the late bug/hardening batch (#470/#477/#489/ #493/#499) and the cosign/e2e/box-layout follow-ups. Cut on the build server with `make release` (runs the #54 integration gate against the real nodes incl. a RigForge v1.8.0 rig, builds, stages, smoke-tests, promotes by digest, cosign-signs, and publishes the tag + GitHub Release). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ VERSION | 2 +- build/dashboard/pyproject.toml | 2 +- build/dashboard/uv.lock | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 155152c7..f21746c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ per the process in [`docs/releasing.md`](docs/releasing.md). ## [Unreleased] +## [1.5.0] - 2026-07-13 + ### Added - **Worker Inspect: view and edit a rig's config from the dashboard (#185).** Click a worker's name in diff --git a/VERSION b/VERSION index 88c5fb89..bc80560f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index e33b97f6..bffaabe7 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -7,7 +7,7 @@ name = "mining-dashboard" # Keep in lockstep with the top-level VERSION file — the single source of truth for the stack version # (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version # from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only. -version = "1.4.0" +version = "1.5.0" description = "Monitoring dashboard and XvB switching engine for Pithead" readme = "README.md" requires-python = ">=3.11" diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index dffb92be..d410830e 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -772,7 +772,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.4.0" +version = "1.5.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 7acead2eac0cebc7c228db528bb30b8b5a3eb83a Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 13 Jul 2026 17:52:15 -0500 Subject: [PATCH 2/4] =?UTF-8?q?release:=20v1.5.0=20hardening=20=E2=80=94?= =?UTF-8?q?=20tier-4=20RigForge=20integration=20test=20+=20doc/alert=20fix?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-release validation for v1.5.0: - test(tier-4): add a RigForge integration phase to tests/integration/run.sh (--rigforge, wired into e2e.sh when a real rig is borrowed). Against a REAL rigforge v1.8.0 rig it asserts the dashboard consumed the enriched feed live (#235/#260) and Worker Inspect reads it + its write path is fail-closed (#185) — the end-to-end validation the tier-2 contract test (fake) can't give. Runs under the shared-bench flock; self-skips if no rigforge rig is connected; non-destructive. - fix(telegram): render telegram.events.payout_confirmed so the confirmed-payout alert (#381/#462) is actually silenceable (it was on-by-default but unrendered), and list it in the event reference + docs. - docs: complete the telegram.events list in configuration.md (db_reset, block_found, payout_found, payout_confirmed, container_unhealthy) and document the #470 per-operator control-prompt rate limit. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 +++ config.reference.json | 1 + docs/configuration.md | 2 +- docs/telegram.md | 6 +++ pithead | 6 ++- tests/integration/e2e.sh | 3 ++ tests/integration/run.sh | 80 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 101 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f21746c5..c1234c92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -165,6 +165,12 @@ per the process in [`docs/releasing.md`](docs/releasing.md). ### Fixed +- **The `payout_confirmed` alert is now listed and silenceable (#381/#462 follow-up).** The + confirmed-on-chain-payout alert shipped on-by-default but `pithead` never rendered + `telegram.events.payout_confirmed`, so it couldn't be turned off and was missing from the event + reference. It's now wired end to end (config.json → `.env` → dashboard) and documented in the + Telegram alert tables alongside the others; set `telegram.events.payout_confirmed: false` to silence + it. - **The shared bench-lock opens read-only, matching RigForge byte-for-byte (#499, ports rigforge#252).** The tier-4 harness's `rig_lock` helper (`tests/integration/lib.sh`) opened its flock file write-open (`exec 9>`); on a shared box where the lock was first created by a non-root reserve, diff --git a/config.reference.json b/config.reference.json index a67598ee..54999f8d 100644 --- a/config.reference.json +++ b/config.reference.json @@ -131,6 +131,7 @@ "high_reject_rate": true, "block_found": true, "payout_found": true, + "payout_confirmed": true, "container_unhealthy": true }, "daily_summary_time": "08:00", diff --git a/docs/configuration.md b/docs/configuration.md index 13aeb115..89264ce9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,7 +121,7 @@ plain HTTP, edit `config.json` and run `./pithead apply`. | `telegram.enabled` | `false` | Push operational alerts (node down/recovered, worker offline/back, sync finished) to Telegram. Off by default. Requires `bot_token` + `chat_id` to actually send. Full walkthrough: [Telegram Bot](telegram.md). | | `telegram.bot_token` | `""` | Your BotFather bot token. A secret — stored owner-only in `.env`, git-ignored, and never logged. Get one from [@BotFather](https://t.me/BotFather). | | `telegram.chat_id` | `""` | Where alerts are sent and the only chat the command interface answers. A Telegram group id (negative, e.g. `-1001234567890`) or a personal chat id. See [how to find it](telegram.md#3-find-your-chat-id). | -| `telegram.events.*` | all `true` | Per-event toggles: `stack_online`, `node_down`, `node_recovered`, `worker_offline`, `worker_recovered`, `worker_joined`, `worker_left`, `sync_finished`, `disk_space`, `db_unhealthy`, `xvb_no_share`, `xvb_registration`, `clearnet_exposed`, `new_release`, `daily_summary`, `hashrate_low`, `hashrate_loss`, `hugepages`, `low_ram`, `wallet_changed`, `high_reject_rate`. Each defaults to on once Telegram is enabled; set one `false` to silence just that alert. Full list: [Telegram Bot](telegram.md#choosing-which-alerts-you-get). | +| `telegram.events.*` | all `true` | Per-event toggles: `stack_online`, `node_down`, `node_recovered`, `worker_offline`, `worker_recovered`, `worker_joined`, `worker_left`, `sync_finished`, `disk_space`, `db_unhealthy`, `db_reset`, `xvb_no_share`, `xvb_registration`, `clearnet_exposed`, `new_release`, `daily_summary`, `hashrate_low`, `hashrate_loss`, `hugepages`, `low_ram`, `wallet_changed`, `high_reject_rate`, `block_found`, `payout_found`, `payout_confirmed`, `container_unhealthy`. Each defaults to on once Telegram is enabled; set one `false` to silence just that alert. Full list: [Telegram Bot](telegram.md#choosing-which-alerts-you-get). | | `telegram.daily_summary_time` | `08:00` | Local time (24-hour `HH:MM`) to push the once-a-day status digest, when the `daily_summary` event is on. Uses the dashboard's timezone (`dashboard.timezone`). A malformed value disables the digest. | | `telegram.commands.enabled` | `false` | Turn on the interactive command interface — the bot answers read-only status queries (`/status`, `/hashrate`, `/workers`, `/luck`, `/earnings`, and more) from the configured `chat_id` (every other chat is ignored). Off by default; alerts work without it. Long-polls over Tor, so it needs no inbound port. Full command list: [Telegram › Commands](telegram.md#commands). | | `notifications.webhooks` | `[]` | Generic JSON webhook alert sinks (#380): every alert the stack produces is POSTed to each listed URL as `{"event", "text", "ts"}` — for Gotify, Home Assistant, or any endpoint that accepts a POST. Empty list keeps it off. The URLs are secrets (query strings often carry tokens): owner-only `.env`, never logged or printed. Sinks carry every event; `telegram.events` gates Telegram only. See [Telegram › Webhook and ntfy sinks](telegram.md#webhook-and-ntfy-sinks). | diff --git a/docs/telegram.md b/docs/telegram.md index 11c4bb66..63ce4d68 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -44,6 +44,7 @@ transition, not a stream: | 🚨 **Payout wallet changed** | The wallet p2pool mines to differs from the last one seen — every future reward goes to the new address. Fires on **every** change, including one you made yourself with `./pithead apply`, so treat it as a confirmation; if you didn't change it, your rewards are being redirected. Addresses appear truncated to 8 characters (full addresses never leave the host), and the dashboard shows a matching top-bar warning for 72 hours — the dashboard warning works even with Telegram off. | | 🎉 **Block found** | The P2Pool sidechain found a Monero block. Pool-wide: every miner with a PPLNS share gets paid from it. | | 💰 **Payout incoming** | That block pays **you** — this node held a share in the PPLNS window when it was found. The amount lands in your wallet once the block matures (about two hours). | +| ✅ **Payout confirmed** | A P2Pool payout (Monero or Tari) matured and was seen **on-chain** by the view-only wallet — ground truth that a reward actually arrived, beside the estimate. Fires once per confirmed payout. Only when a view key is set (see [Payout confirmation](dashboard.md#payout-confirmation)). | | 🆕 **New release** | A newer Pithead release is available (the same signal as the dashboard header badge). | | 🚀 **Pithead online** | Sent once when the dashboard starts — a heartbeat that the stack is up (and confirms the bot works after setup). | | 📅 **Daily summary** | A once-a-day retrospective of the last 24h across your whole fleet — date/time, an **incident roll-up** (what went wrong during the day, or an all-clear), **24h hashrate** with the **P2Pool / XvB split**, **shares found in the day**, an **estimated daily earnings** figure, and a **per-machine 24h breakdown** — pushed at a set local time (**08:00** by default; `telegram.daily_summary_time`). | @@ -177,6 +178,7 @@ block and set it to `false` — any event you don't list stays on: | `high_reject_rate` | `true` | Pool-wide reject rate over the trailing hour crossed 5% (the same threshold as the dashboard's ⚠ flag) / dropped back | | `block_found` | `true` | The P2Pool sidechain found a Monero block (pool-wide — every miner with a PPLNS share gets paid) | | `payout_found` | `true` | That block pays you — this node held a share in the PPLNS window when it was found | +| `payout_confirmed` | `true` | A payout (Monero or Tari) confirmed on-chain by the view-only wallet | | `container_unhealthy` | `true` | A stack container is crash-looping or stuck failing its healthcheck / recovered | Run `./pithead apply` after editing. @@ -280,6 +282,10 @@ How the gating works: until you tap it, and if you don't within `confirm_timeout` seconds (default 60) the request is **denied**, never queued. Only the operator who issued the command can confirm it, and each button is one-shot. +- **Per-operator rate limit.** Each allow-listed operator can be issued at most a handful of confirm + prompts per rolling hour; past that, `/restart` / `/apply` from that operator are dropped without a + prompt (an anti-fatigue guard so a stuck or compromised session can't spam approvals). The budget is + keyed per operator, so one hitting the cap never locks the others out. Not configurable. - **A fixed, small action set.** The two verbs are the whole surface — a Telegram message **selects** one of them, it never becomes a host command. There is no arbitrary execution. - **It rides the config-editor's channel, not a new one.** Both verbs act by dropping a typed intent diff --git a/pithead b/pithead index de5fc4d0..4cc0a36d 100755 --- a/pithead +++ b/pithead @@ -3307,7 +3307,7 @@ render_env() { local tg_ev_xvb_no_share tg_ev_clearnet_exposed tg_ev_xvb_registration tg_ev_new_release tg_ev_stack_online local tg_ev_daily_summary tg_summary_time tg_ev_hashrate_low tg_ev_hashrate_loss local tg_ev_hugepages tg_ev_low_ram tg_ev_wallet_changed tg_ev_high_reject_rate - local tg_ev_block_found tg_ev_payout_found tg_ev_container_unhealthy + local tg_ev_block_found tg_ev_payout_found tg_ev_payout_confirmed tg_ev_container_unhealthy local hr_drop_threshold hr_drop_minutes tg_ev_node_down=$(tg_event node_down) tg_ev_node_recovered=$(tg_event node_recovered) @@ -3333,6 +3333,7 @@ render_env() { tg_ev_high_reject_rate=$(tg_event high_reject_rate) tg_ev_block_found=$(tg_event block_found) tg_ev_payout_found=$(tg_event payout_found) + tg_ev_payout_confirmed=$(tg_event payout_confirmed) tg_ev_container_unhealthy=$(tg_event container_unhealthy) # Degradation detector (#99): drop-below-% and sustained-minutes; defaults 50 / 10. hr_drop_threshold=$(jq -r '.dashboard.hashrate_drop_threshold // 50' "$CONFIG_FILE") @@ -3501,6 +3502,7 @@ TELEGRAM_EVENT_WALLET_CHANGED=$tg_ev_wallet_changed TELEGRAM_EVENT_HIGH_REJECT_RATE=$tg_ev_high_reject_rate TELEGRAM_EVENT_BLOCK_FOUND=$tg_ev_block_found TELEGRAM_EVENT_PAYOUT_FOUND=$tg_ev_payout_found +TELEGRAM_EVENT_PAYOUT_CONFIRMED=$tg_ev_payout_confirmed TELEGRAM_EVENT_CONTAINER_UNHEALTHY=$tg_ev_container_unhealthy HASHRATE_DROP_THRESHOLD_PCT=$hr_drop_threshold HASHRATE_DROP_MINUTES=$hr_drop_minutes @@ -4622,7 +4624,7 @@ CONTROL_DASHBOARD_EDITABLE_KEYS='P2POOL_FLAGS P2POOL_PORT TELEGRAM_EVENT_HASHRATE_LOW TELEGRAM_EVENT_HASHRATE_LOSS TELEGRAM_EVENT_HUGEPAGES TELEGRAM_EVENT_LOW_RAM TELEGRAM_EVENT_HIGH_REJECT_RATE TELEGRAM_EVENT_BLOCK_FOUND - TELEGRAM_EVENT_PAYOUT_FOUND TELEGRAM_EVENT_CONTAINER_UNHEALTHY' + TELEGRAM_EVENT_PAYOUT_FOUND TELEGRAM_EVENT_PAYOUT_CONFIRMED TELEGRAM_EVENT_CONTAINER_UNHEALTHY' control_approval_gate() { # local staged="$1" porcelain diff --git a/tests/integration/e2e.sh b/tests/integration/e2e.sh index 52220a57..cd8cf2df 100755 --- a/tests/integration/e2e.sh +++ b/tests/integration/e2e.sh @@ -395,6 +395,9 @@ run_harness() { targeted) phases="--readiness --auth-fail-closed --lifecycle" ;; # --readiness/--check run first below matrix) phases="--safety-backup --lifecycle --fault-injection --auth-fail-closed --hardening" ;; esac + # RigForge integration (#185/#235/#260) is only meaningful with a REAL rig mining through the stack. + # The phase self-skips if no rigforge rig is connected, so this gate is just to avoid the noise. + [ "$BORROW_MINER" = "1" ] && [ "$MODE" != "check" ] && phases="$phases --rigforge" log "Running the live harness on $BENCH_HOST (mode=$MODE, detached so an SSH drop can't kill it)" step "phases: $phases (workers=$WORKERS)" diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 162c13cf..e98f73ba 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -39,6 +39,7 @@ RUN_LIFECYCLE=0 RUN_FAULTS=0 RUN_AUTH_FAIL_CLOSED=0 RUN_HARDENING=0 +RUN_RIGFORGE=0 SAFETY_BACKUP=0 SAFETY_ARCHIVE="" KEEP_STATE=0 @@ -108,6 +109,9 @@ MATRIX: on a spooled request (allowlisted change applies, sensitive change refused), and the stack recovers from a tor restart. DESTRUCTIVE-then- restored (enables then disables the control channel). + --rigforge also run the RigForge integration phase (#185/#235/#260): assert the + dashboard consumed a REAL rigforge rig's enriched feed and Worker Inspect + reads it. Non-destructive; self-skips if no rigforge rig is connected. --keep do NOT restore the original config.json at the end (leaves the box on the last scenario — useful for debugging) @@ -195,6 +199,10 @@ parse_args() { RUN_HARDENING=1 shift ;; + --rigforge) + RUN_RIGFORGE=1 + shift + ;; --safety-backup) SAFETY_BACKUP=1 shift @@ -1501,6 +1509,75 @@ summary() { return 0 } +# --- RigForge integration phase (--rigforge) -------------------------------- +# The v1.5 dashboard <-> RigForge integration, validated against a REAL rig (the borrowed loaner) — +# the one thing the tier-2 contract test (fakes/test_contract.py, #209) can't prove: that a real +# RigForge v1.8.0 producer serves the enriched feed in the shape the dashboard consumes, and that the +# LIVE dashboard reads it. Self-gates: if no worker exposes a rigforge enriched block (no real rig, or +# its sister API is off), it warns and skips — it never fails the gate on a bench without a rigforge +# rig. Non-destructive: it reads /api/state + /api/worker and checks the write path's fail-closed +# guards; it does NOT push a config change to the borrowed rig (that stage->apply->RESTART->rollback +# flow restarts a shared loaner and needs the rig's own control API opted in, so it's a manual runbook +# step). Runs under the shared-bench flock like the rest of main() (rig_lock is already held). +run_rigforge_integration() { + # shellcheck disable=SC2034 # read by lib.sh:it_fail to label captured failures + IT_CURRENT_SCENARIO="rigforge-integration" + echo "" + it_log "── RigForge integration phase (#185/#235/#260) ─────" + local st rig + st="$(api_state)" + if [ -z "$st" ]; then + it_warn "rigforge-integration: /api/state unreachable — skipping" + return 0 + fi + # A worker whose enriched rigforge block is present (version populated) = a real RigForge rig + # whose sister API the dashboard reached and parse_rigforge parsed (#235). + rig="$(printf '%s' "$st" | jq -r 'first(.workers[]? | select(.rigforge != null and .rigforge.version != null) | .name) // empty' 2>/dev/null)" + if [ -z "$rig" ]; then + it_warn "no worker exposes a RigForge enriched feed (a real rigforge rig with api:enabled on :8081?) — enriched-feed consumption not asserted here; the parse contract is covered by the tier-2 test" + return 0 + fi + it_pass "dashboard consumed a real RigForge rig's enriched feed: $rig (#235)" + + # 1. Enriched feed (#235/#260): the rig's row carries a version + at least one health/power/tune + # chip, so parse_rigforge ran on the REAL feed, not a fixture. + local ver nchips + ver="$(printf '%s' "$st" | jq -r --arg n "$rig" 'first(.workers[] | select(.name==$n) | .rigforge.version) // empty' 2>/dev/null)" + assert_ne "rigforge version present in the live feed" "$ver" "" + nchips="$(printf '%s' "$st" | jq -r --arg n "$rig" '[.workers[] | select(.name==$n) | .rigforge.chips[]?.text] | length' 2>/dev/null)" + assert_num_ge "rigforge health/power/tune chips surfaced (>=1)" "${nchips:-0}" 1 + + # 2. Worker Inspect (#185) rides on the control channel (fail-closed): the /api/worker route only + # exists when dashboard.control is on. Off here → the enriched-feed leg above still proves + # #235/#260; the control path itself is covered by the hardening phase + the tier-2 test. + if [ "$(printf '%s' "$st" | jq -r '.control_enabled // false' 2>/dev/null)" != "true" ]; then + it_warn "dashboard.control off — Worker Inspect (#185) read/write not exposed here (covered by the hardening phase + tier-2 contract); enriched-feed consumption validated" + return 0 + fi + + # 2a. Worker Inspect READ: GET /api/worker?name= returns the rig's detail carrying the + # enriched telemetry (plus the config prefill + history). + local detail + detail="$(rx "curl -fsS --max-time 10 $(quote_arg "http://127.0.0.1:8000/api/worker?name=$rig")" 2>/dev/null || true)" + if [ -n "$detail" ] && printf '%s' "$detail" | jq -e '.name' >/dev/null 2>&1; then + it_pass "Worker Inspect read returns the rig's detail (#185)" + assert_ne "worker detail carries the enriched telemetry" \ + "$(printf '%s' "$detail" | jq -r '(.rigforge.version // .telemetry.rigforge.version // .telemetry.version) // empty' 2>/dev/null)" "" + else + it_fail "Worker Inspect read returns the rig's detail (#185)" "GET /api/worker returned no valid JSON: ${detail:0:120}" + fi + + # 2b. Worker Inspect WRITE-path fail-closed guards (#185) — proven WITHOUT mutating the borrowed + # rig: a POST missing the X-Pithead-Control CSRF header is refused (403), and a non-writable key + # is rejected (400). The full dashboard->rig apply+rollback is the manual runbook step. + local code_noheader code_badkey + code_noheader="$(rx "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{\"worker\":\"$rig\",\"changes\":{\"DONATION\":1}}' http://127.0.0.1:8000/api/control/worker-apply" 2>/dev/null || echo 000)" + assert_eq "Worker Inspect write refuses a request without the control header (403, #185)" "$code_noheader" "403" + code_badkey="$(rx "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' -H 'X-Pithead-Control: 1' --data '{\"worker\":\"$rig\",\"changes\":{\"ACCESS_TOKEN\":\"x\"}}' http://127.0.0.1:8000/api/control/worker-apply" 2>/dev/null || echo 000)" + assert_eq "Worker Inspect write rejects a non-writable key (400, #185)" "$code_badkey" "400" + it_step "full dashboard->rig config push + rollback (#185) is a manual runbook step — it restarts the borrowed loaner and needs the rig's control API opted in" +} + # --- Main ------------------------------------------------------------------- IT_SKIPPED=0 @@ -1561,6 +1638,9 @@ main() { done < <(scenario_matrix) fi + # RigForge integration (#185/#235/#260) first — it's read-only and wants the freshly-mined + # state with the borrowed rig connected, before the destructive phases churn the stack. + [ "$RUN_RIGFORGE" = "1" ] && run_rigforge_integration [ "$RUN_LIFECYCLE" = "1" ] && run_lifecycle [ "$RUN_FAULTS" = "1" ] && run_fault_injection [ "$RUN_AUTH_FAIL_CLOSED" = "1" ] && run_auth_fail_closed From 41d9a19402e34e00a42d166da5228a9353c3ee2e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 13 Jul 2026 19:30:08 -0500 Subject: [PATCH 3/4] test(e2e): make the real-rig tier-4 run actually exercise its phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three harness bugs surfaced while validating v1.5 against a live rigforge-1.8.0 rig on the bench: - backup_stack: `pithead backup -y` refuses to write a plaintext archive unattended (v1.4 hardening) unless PITHEAD_BACKUP_PASSPHRASE is set, so the safety-backup phase aborted the whole run. Pass --no-encrypt — this rollback anchor never leaves the bench, so plaintext is acceptable. - run_harness (targeted): --readiness was handed to the DETACHED run.sh alongside the real phases, but run.sh returns right after readiness, so --auth-fail-closed / --lifecycle / --rigforge silently never ran. Readiness already runs inline first; drop it from the detached phase list. - borrow_miner: only reordered existing pools, assuming a bench pool was already configured. A stock miner has none, so it kept mining to its own pool and 0 workers ever connected. Inject a bench pool (cloned from pool[0] so creds/keepalive carry over) when none matches the bench. With these, `e2e.sh --targeted` borrows a real rig and the RigForge phase (#185/#235/#260) validates the dashboard consuming its live enriched feed: 306 passed / 2 env-skipped / 0 failed. Co-Authored-By: Claude Opus 4.8 --- tests/integration/e2e.sh | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/integration/e2e.sh b/tests/integration/e2e.sh index cd8cf2df..b732796d 100755 --- a/tests/integration/e2e.sh +++ b/tests/integration/e2e.sh @@ -341,7 +341,9 @@ provision() { # --- Phase 2: safety backup of the live stack ------------------------------- backup_stack() { log "Taking a safety backup of the live stack (the rollback anchor)" - on_bench "cd '$CANONICAL_DIR' && ./pithead backup -y >/dev/null 2>&1" || die "pithead backup failed." + # ponytail: --no-encrypt because v1.4 refuses to write a plaintext archive unattended without + # PITHEAD_BACKUP_PASSPHRASE; this rollback anchor never leaves the bench, so plaintext is fine here. + on_bench "cd '$CANONICAL_DIR' && ./pithead backup -y --no-encrypt >/dev/null 2>&1" || die "pithead backup failed." SAFETY_ARCHIVE="$(on_bench "ls -t '$CANONICAL_DIR'/backups/pithead-backup-*.tar.gz 2>/dev/null | head -n1")" [ -n "$SAFETY_ARCHIVE" ] || die "Backup ran but produced no archive." ok "safety backup: $SAFETY_ARCHIVE" @@ -357,10 +359,15 @@ borrow_miner() { MINER_CFG_BACKUP="$MINER_XMRIG_CONFIG.e2e-orig.$(on_miner 'date +%Y%m%d-%H%M%S')" on_miner "cp -a '$MINER_XMRIG_CONFIG' '$MINER_CFG_BACKUP'" || die "Failed to back up the miner config." step "miner config backed up → $MINER_CFG_BACKUP" - # Reorder pools so the test-bench pool is primary (index 0); keep the rest as failover. Non-destructive - # and fully reversible from the backup above. Assumes a test-bench pool already exists in the config. + # Point the rig at the bench: inject a bench pool if the config has none (clone pool[0] so + # user/pass/keepalive carry over, override url→bench and force plain stratum), then reorder so the + # bench pool is primary and the rest stay as failover. Non-destructive, fully reversible from the + # backup above. ponytail: hardcodes :3333 (the seeded canonical stratum_port default, which the bench runs). on_miner " - jq '.pools |= ([.[] | select(.url | ascii_downcase | contains(\"$BENCH_HOST\"))] + [.[] | select(.url | ascii_downcase | contains(\"$BENCH_HOST\") | not)])' \ + jq --arg b '$BENCH_HOST' ' + (if any(.pools[]?; .url | ascii_downcase | contains(\$b)) then . + else .pools = ([ (.pools[0]) + {url: (\$b + \":3333\"), tls: false, daemon: false} ] + .pools) end) + | .pools |= ([.[] | select(.url | ascii_downcase | contains(\$b))] + [.[] | select(.url | ascii_downcase | contains(\$b) | not)])' \ '$MINER_XMRIG_CONFIG' > '$MINER_XMRIG_CONFIG.e2e.tmp' \ && mv '$MINER_XMRIG_CONFIG.e2e.tmp' '$MINER_XMRIG_CONFIG' && chmod 600 '$MINER_XMRIG_CONFIG' " || die "Failed to repoint the miner config." @@ -392,7 +399,7 @@ run_harness() { local phases case "$MODE" in check) phases="--check" ;; - targeted) phases="--readiness --auth-fail-closed --lifecycle" ;; # --readiness/--check run first below + targeted) phases="--auth-fail-closed --lifecycle" ;; # readiness/check run inline first (below); NOT here — run.sh returns after --readiness matrix) phases="--safety-backup --lifecycle --fault-injection --auth-fail-closed --hardening" ;; esac # RigForge integration (#185/#235/#260) is only meaningful with a REAL rig mining through the stack. From 68a3937c7281e86c91b9c70dba264c6c3e97c9ac Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Mon, 13 Jul 2026 19:47:09 -0500 Subject: [PATCH 4/4] fix(control): worker-apply charset check fails closed under UTF-8 locales (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit control_worker_apply pinned the worker name with `grep -qE '^[!-~]{1,128}$'`. GNU grep reads the [!-~] range by LOCALE COLLATION, so on any UTF-8-locale host (i.e. every real deployment) it rejects ordinary names like "rig1" with "malformed or missing 'worker' name" — worker config edits were dead on arrival. CI runs under a C/POSIX locale and passed; the release-gate stack suite on a UTF-8 box (gouda) caught it. Force LC_ALL=C so [!-~] is the printable-ASCII byte range, matching the intent and the jq test() checks elsewhere (which are codepoint-based and unaffected). Co-Authored-By: Claude Opus 4.8 --- pithead | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pithead b/pithead index 4cc0a36d..178d4c11 100755 --- a/pithead +++ b/pithead @@ -5036,7 +5036,10 @@ control_worker_apply() { # local worker changes worker=$(jq -r '.worker // ""' "$file") # The worker name is a config.json lookup key AND (in name-auth) a bearer; pin its charset. - if ! printf '%s' "$worker" | grep -qE '^[!-~]{1,128}$'; then + # LC_ALL=C so [!-~] is the printable-ASCII BYTE range: under a UTF-8 locale GNU grep reads the + # range by collation order and rejects ordinary names like "rig1" (caught by the release gate on a + # UTF-8 box; CI runs under C and missed it). jq's test() above is codepoint-based and unaffected. + if ! printf '%s' "$worker" | LC_ALL=C grep -qE '^[!-~]{1,128}$'; then _wa_reject "malformed or missing 'worker' name in the request." return 0 fi