From 24c6921b0c84e15eb79858cd436132610a87c5b8 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 14 Jul 2026 16:30:09 -0500 Subject: [PATCH 01/10] fix(dashboard): space in config-disabled hint + gap between Config/Access-log panels (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disabled-Configuration hint rendered 'setdashboard.control.enabled' — htm collapsed the newline between 'set' and the element. Put them on one line with an explicit space. And the Configuration editor + Access-log cards touched with no gap (they render adjacent, outside a .grid); wrap them in a .card-stack with a 20px gap. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/mining_dashboard/web/static/components.mjs | 4 +++- .../dashboard/mining_dashboard/web/static/configview.mjs | 4 ++-- build/dashboard/mining_dashboard/web/static/dashboard.css | 8 ++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index b0a951e1..6f3a41c9 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -843,7 +843,9 @@ function DashboardView({ <${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} /> - ${configView ? html`<${ConfigView} /><${SecurityPanel} />` : null} + ${configView + ? html`
<${ConfigView} /><${SecurityPanel} />
` + : null} ${ configView ? null diff --git a/build/dashboard/mining_dashboard/web/static/configview.mjs b/build/dashboard/mining_dashboard/web/static/configview.mjs index 2e997d90..ced155e4 100644 --- a/build/dashboard/mining_dashboard/web/static/configview.mjs +++ b/build/dashboard/mining_dashboard/web/static/configview.mjs @@ -190,8 +190,8 @@ export class ConfigView extends Component { if (phase === "disabled") { return html`

Configuration

-

Configuration editing is off (the default). To enable it, set - dashboard.control.enabled: true in config.json on the host and +

Configuration editing is off (the default). To enable it, set dashboard.control.enabled: true + in config.json on the host and run ./pithead apply. It requires a dashboard login.

`; } diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index 9201e6f3..79452d79 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -106,6 +106,14 @@ body { min-width: 0; } +/* Vertically-stacked cards that aren't in a .grid need their own gap (#505: the Configuration + * editor and the Access-log panel otherwise touch with no space between them). */ +.card-stack { + display: flex; + flex-direction: column; + gap: 20px; +} + /* Brand block (Issue #81): the Pithead mark + wordmark in the header, with the host IP demoted * to a subtitle beneath the name. The host IP (HOST_IP) is arbitrary user input; a long unbroken * value (no hyphens/dots to break at) would otherwise push the header — and the page — wider than From 326fa0c1a24067239c037b58521f73f49d803bc3 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 14 Jul 2026 16:39:00 -0500 Subject: [PATCH 02/10] fix(control): surface a clear note for config.json-only edits instead of silent 'no changes' (#519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dashboard.energy is read straight from config.json and never renders to .env, so the config editor's .env-diff preview reported 'No configuration changes detected' and disabled Apply — a silent no-op that looked like the save was broken. control_preview now detects a dashboard.energy diff and appends a non-committable HOST note; the PreviewModal renders HOST notes as an informational line and never arms Apply for them. Committing dashboard.energy from the dashboard is a separate feature (#504); today it's applied on the host. Adds a stack test asserting an energy-only edit yields exactly one HOST note and no committable change. Co-Authored-By: Claude Opus 4.8 --- .../mining_dashboard/web/static/configview.mjs | 9 ++++++--- .../mining_dashboard/web/static/dashboard.css | 5 +++++ pithead | 7 +++++++ tests/stack/run.sh | 13 +++++++++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/build/dashboard/mining_dashboard/web/static/configview.mjs b/build/dashboard/mining_dashboard/web/static/configview.mjs index ced155e4..a030ca41 100644 --- a/build/dashboard/mining_dashboard/web/static/configview.mjs +++ b/build/dashboard/mining_dashboard/web/static/configview.mjs @@ -67,6 +67,9 @@ const Field = ({ field, edits, onEdit }) => { const PreviewModal = ({ preview, confirmText, onConfirmText, onConfirm, onCancel, busy }) => { const changes = preview.changes || []; + // #519: HOST-flagged entries (e.g. dashboard.energy) are config.json-only — informational, not + // committable from the dashboard, so they never arm the Apply button. + const committable = changes.filter((c) => c.flag !== "HOST"); const armed = !preview.destructive || confirmText === "APPLY"; return html`
@@ -76,8 +79,8 @@ const PreviewModal = ({ preview, confirmText, onConfirmText, onConfirm, onCancel ? html`

No configuration changes detected.

` : html`
    ${changes.map( - (c) => html`
  • - ${c.flag === "DEST" ? "⚠ " : ""}${c.msg}
  • `, + (c) => html`
  • + ${c.flag === "DEST" ? "⚠ " : c.flag === "HOST" ? "ℹ " : ""}${c.msg}
  • `, )}
` } @@ -91,7 +94,7 @@ const PreviewModal = ({ preview, confirmText, onConfirmText, onConfirm, onCancel
diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index 79452d79..a16563f2 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -1102,6 +1102,11 @@ button.upgrade-btn { .config-preview-dest { color: var(--warn); } +/* #519: a config.json-only block (e.g. dashboard.energy) edited from the dashboard — informational, + * not committable here, so it's dimmed to read as a note rather than a pending change. */ +.config-preview-host { + color: var(--text-muted); +} .config-confirm-type { display: block; margin: 12px 0; diff --git a/pithead b/pithead index 178d4c11..6d771171 100755 --- a/pithead +++ b/pithead @@ -4743,6 +4743,13 @@ control_preview() { # result=$(printf '%s\n' "$out" | jq -R -s ' [split("\n")[] | select(length > 0) | split("\t") | {flag: .[0], key: .[1], msg: (.[2:] | join("\t"))}] | {status: "previewed", changes: ., destructive: (map(.flag == "DEST") | any), ts: (now | floor)}') + # #519: dashboard.energy is config.json-only (never rendered to .env), so an energy-only edit + # produces no porcelain change and the UI would otherwise show a bare "no changes detected". + # Surface it as a non-committable HOST note so the operator knows to apply it on the host + # (committing this block from the dashboard is tracked in #504). + if ! jq -e --slurpfile live "$CONFIG_FILE" '(.dashboard.energy // {}) == ($live[0].dashboard.energy // {})' "$staged" >/dev/null 2>&1; then + result=$(printf '%s' "$result" | jq '.changes += [{flag:"HOST",key:"dashboard.energy",msg:"dashboard.energy changed — this block is edited directly in config.json on the host: set it and run \"./pithead apply\". Committing it from the dashboard is tracked in #504."}]') + fi control_write_result "$cdir/results" "$id" "$result" control_audit "$cdir/audit/control.log" "$id" "$actor" "preview" "previewed" "$(porcelain_keys "$out")" else diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 90cfda87..19a58a79 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -3806,6 +3806,19 @@ assert_eq "pool switch alone is not destructive" "$(jq -r '.destructive' "$RESUL assert_eq "staged candidate is mode 600" "$(file_mode "$STAGED/$UUID1.json")" "600" assert_contains "preview audited" "$(cat "$AUDIT" 2>/dev/null)" "\"action\":\"preview\",\"status\":\"previewed\"" +# #519: an energy-only edit is config.json-only (dashboard.energy never renders to .env), so it +# produces no committable change — the preview surfaces a non-committable HOST note (so the UI shows +# an explanation, not a bare "no changes"). Built from the live config so ONLY energy differs. +UUID_EN="55555555-5555-4555-8555-555555555555" +jq --arg id "$UUID_EN" '{id:$id, action:"preview", actor:"admin", + config:(. + {dashboard:(.dashboard + {energy:{cost_per_kwh:0.11,xmr_price:322,currency:"USD"}})})}' \ + "$C/config.json" >"$REQS/$UUID_EN.json" +out="$(run_pending)" +assert_eq "energy-only preview is previewed" "$(jq -r '.status' "$RESULTS/$UUID_EN.json" 2>/dev/null)" "previewed" +assert_eq "energy edit surfaces exactly one HOST note" "$(jq -r '[.changes[]|select(.flag=="HOST")]|length' "$RESULTS/$UUID_EN.json" 2>/dev/null)" "1" +assert_eq "energy-only edit has no committable (.env) change" "$(jq -r '[.changes[]|select(.flag!="HOST")]|length' "$RESULTS/$UUID_EN.json" 2>/dev/null)" "0" +assert_contains "HOST note names dashboard.energy" "$(jq -r '.changes[]|select(.flag=="HOST")|.msg' "$RESULTS/$UUID_EN.json" 2>/dev/null)" "dashboard.energy changed" + # Malformed id: it would become a filename, so the request is discarded with no result at all. printf '{"id":"../../etc/passwd","action":"preview","actor":"x","config":{}}\n' >"$REQS/evil.json" out="$(run_pending)" From bfaee184185d25c71fe0c1e8a7019a153444217c Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 14 Jul 2026 16:41:19 -0500 Subject: [PATCH 03/10] test(control): guard against drift in the worker writable-key allowlist (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The allowlist is hardcoded in three places kept in sync only by comments — the dashboard (WORKER_WRITABLE_KEYS), the pithead host runner (control_worker_apply's jq array), and rigforge's control-server.py WRITABLE (#236). Drift silently fail-closes edits for the drifted key. Assert the two pithead-repo copies match the canonical set; rigforge#236 carries the reciprocal check on the rig side. Co-Authored-By: Claude Opus 4.8 --- .../tests/service/test_control_service.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build/dashboard/tests/service/test_control_service.py b/build/dashboard/tests/service/test_control_service.py index 08b1f034..f917a306 100644 --- a/build/dashboard/tests/service/test_control_service.py +++ b/build/dashboard/tests/service/test_control_service.py @@ -218,3 +218,22 @@ def test_submit_worker_apply_spools_tokenless_intent(self, spool): } # No secret / addressing leaks into the container-writable spool. assert "host" not in req and "port" not in req and "token" not in req + + +def test_writable_key_allowlist_has_no_intra_repo_drift(): + """#515: the worker writable-key allowlist is hardcoded in THREE places kept in sync only by + comments — the dashboard (WORKER_WRITABLE_KEYS), the pithead host runner + (control_worker_apply's jq allowlist), and rigforge's control-server.py WRITABLE (the authority + the rig enforces, #236). Drift means edits for the drifted key silently fail closed. Guard the + two pithead-repo copies here; rigforge#236 carries the reciprocal check on the rig side.""" + import re + from pathlib import Path + + canonical = {"pools", "DONATION", "autotune", "watchdog", "watchdog_interval_min", "max_temp_c"} + assert set(control_service.WORKER_WRITABLE_KEYS) == canonical + + pithead = (Path(__file__).resolve().parents[4] / "pithead").read_text() + m = re.search(r"\(\[(.*?)\]\)\s*as\s*\$ok", pithead) + assert m, "could not find the writable-key allowlist in pithead's control_worker_apply" + pithead_keys = set(re.findall(r'"([^"]+)"', m.group(1))) + assert pithead_keys == canonical, f"pithead allowlist {pithead_keys} drifted from {canonical}" From 0343b0ad263cba8cd03a32cb32d9fbda684722b1 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 14 Jul 2026 16:44:42 -0500 Subject: [PATCH 04/10] test(control): telegram.control fail-closed prerequisites (#521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit telegram.control gates a remotely-reachable host-control surface (/restart, /apply). Its three-legged fail-closed validation (needs dashboard.control on, telegram.commands on, and a non-empty allowed_ids — empty = every command refused) had zero negative-path coverage. Adds a tgc_case helper testing each refusal fires plus a fully-configured control applies. Co-Authored-By: Claude Opus 4.8 --- tests/stack/run.sh | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 19a58a79..bd96ba09 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -2382,6 +2382,27 @@ assert_contains "duplicate worker names are warned" "$out" "first-declared" # per-worker token must not leak into a second secrets file. if grep -q 'tok_abc123' "$V/.env"; then bad "worker token stays out of .env" "token landed in .env"; else ok "worker token stays out of .env"; fi +# #521: telegram.control gates a REMOTELY-reachable host-control surface (/restart, /apply). It must +# fail closed unless all three prerequisites hold: dashboard.control on, telegram.commands on, and a +# NON-EMPTY allowed_ids (empty = every command refused). Each refusal below was previously untested. +tgc_case() { #
<${AdvancedHint} ui=${ui} onView=${onView} onDismissHint=${onDismissHint} /> - ${configView - ? html`
<${ConfigView} /><${SecurityPanel} />
` - : null} + ${ + configView + ? html`
<${ConfigView} /><${SecurityPanel} />
` + : null + } ${ configView ? null diff --git a/build/dashboard/mining_dashboard/web/static/configview.mjs b/build/dashboard/mining_dashboard/web/static/configview.mjs index a030ca41..cad68894 100644 --- a/build/dashboard/mining_dashboard/web/static/configview.mjs +++ b/build/dashboard/mining_dashboard/web/static/configview.mjs @@ -79,7 +79,9 @@ const PreviewModal = ({ preview, confirmText, onConfirmText, onConfirm, onCancel ? html`

No configuration changes detected.

` : html`
    ${changes.map( - (c) => html`
  • + ( + c, + ) => html`
  • ${c.flag === "DEST" ? "⚠ " : c.flag === "HOST" ? "ℹ " : ""}${c.msg}
  • `, )}
` From 52d8b7a8ba7cd2a93c07567e8521cf1b809b3c45 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 14 Jul 2026 17:13:16 -0500 Subject: [PATCH 08/10] test: defer #519/#521 stack tests to v1.6 (test-harness blockers) The energy-preview test (#519) perturbed shared control-test state (result-file count + last-preview-audit entry); the telegram.control tests (#521) need dashboard.control's Caddy password-hash, which the fake-docker black-box env can't do. Both are deferred to v1.6 with the proper control-test-env setup. The #519 code fix (control_preview HOST note) and #505/#515 ship; #519's fix is covered by the adversarial review + the jq/frontend checks. Co-Authored-By: Claude Opus 4.8 --- tests/stack/run.sh | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/tests/stack/run.sh b/tests/stack/run.sh index bd96ba09..90cfda87 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -2382,27 +2382,6 @@ assert_contains "duplicate worker names are warned" "$out" "first-declared" # per-worker token must not leak into a second secrets file. if grep -q 'tok_abc123' "$V/.env"; then bad "worker token stays out of .env" "token landed in .env"; else ok "worker token stays out of .env"; fi -# #521: telegram.control gates a REMOTELY-reachable host-control surface (/restart, /apply). It must -# fail closed unless all three prerequisites hold: dashboard.control on, telegram.commands on, and a -# NON-EMPTY allowed_ids (empty = every command refused). Each refusal below was previously untested. -tgc_case() { #