cockpit: the deck — tmux-style pane grid + the color-camouflaged wizard

Operator asked for a tmux-type panes grid in the browser (one pane per
role, all acting in parallel, like real life) with a wizard on the right
that takes a newcomer by the hand through every role's actions, each
instruction camouflaged in that role's color.

- /deck: CSS-grid of six live panes (2-col, 3-col on wide screens),
  one per station, each an iframe onto /station/<id>?pane=1. tmux
  controls per pane: reload, single-pane zoom, open-full. Narrow
  screens: wizard first, panes stacked.
- pane mode (?pane=1): chrome-stripped shell (no h1/banner/nav), same
  station content, READ-ONLY label kept; an 8-line shim re-carries
  pane=1 on every same-origin link and form submit, so probes, incident
  browsing, and inspect verdicts all happen inside the pane.
- the wizard: a 10-step guided first watch across all six roles on the
  live demo wallet. Each step card wears the role's hue with a 'YOU ARE
  THE <ROLE>' chip, the matching pane glows, and every step states what
  success looks like + what was just learned. Step remembered per
  session (sessionStorage).
- /inspect?sample=1 pre-fills examples/wallet-evidence so the
  cryptographer step verifies (then deliberately breaks) real evidence.
- verified in a real browser: step navigation moves the glow, panes
  load their stations, Probe-now inside the pane probed live inside the
  pane (log head tree_size 13), sample flow prefilled the key in-pane.

Suite 139 -> 142 green. Read-only guarantee unchanged; byte sweep
covers /deck and pane routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mrwulf 2026-07-21 17:04:23 +02:00
parent a18877612d
commit 15421ac4d0
5 changed files with 483 additions and 24 deletions

View file

@ -91,6 +91,38 @@ never-list/handoffs on all six), `test_stations_are_distinct_roles`
(each role's signature phrase appears on its own station and on no
other — no melting), `test_operator_probe_is_explicit_and_live`.
## The deck (`/deck`) — all roles live, in parallel, with the wizard
The **deck** is the crew law made physical: a tmux-style grid of six
panes, one per role, all live at the same time — because a real crew
works in parallel, roles do not take turns existing. Each pane is an
independent viewport (an iframe onto that role's station in
chrome-stripped **pane mode**, `?pane=1`): it scrolls, reloads (⟳), and
zooms (⤢, tmux-style single-pane zoom) independently, and a tiny shim
keeps every link and form inside the pane (`pane=1` is re-carried), so
pressing «Probe now» in the operator pane runs the probe *in that pane*.
Pane mode strips the page chrome but keeps the READ-ONLY label and the
full station content — one source of truth, two shells.
On the right rides the **wizard**: a ten-step guided first watch that
takes a newcomer by the hand through every role's real actions on the
live demo wallet — probe as the operator, read the queue and a refusal
as the proposer, find the dissenting seat as the bench, verify (and then
deliberately break) real sample evidence as the cryptographer
(`/inspect?sample=1` pre-fills `examples/wallet-evidence/`), check the
drift tripwire as the architect, then run the handoff lap. Each step
card is **camouflaged in the color of the role being lived** ("YOU ARE
THE OPERATOR"), and the matching pane **glows** — instruction and
instrument are bound by hue. Every step states what success looks like
and what was just learned. Step position is remembered per browser
session.
Deck contract tests: `test_deck_serves_all_panes_and_wizard` (six live
panes + all six roles visited by the wizard + success criteria),
`test_pane_mode_is_chromeless_but_labeled` (no chrome, READ-ONLY label,
stay-in-pane shim), `test_inspect_sample_prefill`; the read-only byte
sweep covers `/deck` and pane routes.
## The read-only guarantee
The cockpit cannot approve, sign, unlatch, or modify custody state. It

View file

@ -693,7 +693,8 @@ def cmd_wallet_cockpit(args: argparse.Namespace) -> int:
server = serve(wallet_dir, host=args.host, port=args.port)
host, port = server.server_address[0], server.server_address[1]
print(f"warden cockpit (READ-ONLY) on http://{host}:{port} - Ctrl-C to stop")
print(f" first time? start at http://{host}:{port}/guide - every term explained")
print(f" the deck: http://{host}:{port}/deck - all six roles live, guided by the wizard")
print(f" the guide: http://{host}:{port}/guide - every term explained")
try:
server.serve_forever()
except KeyboardInterrupt:

281
src/pacta/deck.py Normal file
View file

@ -0,0 +1,281 @@
"""deck - the ops deck: every role station live in one tmux-style grid,
with the guided wizard rail.
The deck is the crew law made physical: six panes, one per role, all
live at the same time - because in real life the roles act in parallel,
they do not take turns existing. Each pane is an independent viewport
(iframe) onto that role's station in chrome-stripped "pane mode"; panes
reload and zoom independently, tmux-style.
On the right rides the WIZARD: a guided first watch that takes a
newcomer by the hand through every role's real actions on the live DEMO
wallet. Each step card is camouflaged in the color of the role being
lived, and the matching pane glows - instruction and instrument are
bound by hue, so the learner always knows where to act.
Pure presentation: this module renders strings from the station model;
all live evidence stays inside the panes, which are ordinary cockpit
routes and therefore inherit the read-only guarantee wholesale. The
~60 lines of vanilla JS here do layout only (zoom, reload, step
navigation, glow) - they never touch wallet data.
"""
from __future__ import annotations
from .stations import STATIONS
from .uikit import STYLE, esc
_DECK_EXTRA_STYLE = """
body.deckbody{max-width:none;margin:0;padding:0;height:100vh;display:flex;
flex-direction:column;overflow:hidden}
.deckbar{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;
padding:.45rem .9rem;background:#1c2430;color:#e8ecf2;font-size:.82rem}
.deckbar a{color:#aebcf0;text-decoration:none}
.deckbar .mono{opacity:.75}
.deckgrid{flex:1;display:grid;grid-template-columns:minmax(0,1fr) 21.5rem;
min-height:0}
.deckmain{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;
gap:6px;padding:6px;min-height:0;overflow:auto}
@media(min-width:1500px){.deckmain{grid-template-columns:1fr 1fr 1fr}}
.pane{display:flex;flex-direction:column;border:1px solid var(--line);
border-left:4px solid var(--role);border-radius:6px;background:#fff;
min-height:13rem;min-width:0}
.pane header{display:flex;align-items:center;gap:.45rem;padding:.22rem .5rem;
font-size:.78rem;background:var(--roletint);border-radius:0 5px 0 0}
.pane header .q{color:var(--ink2);font-size:.7rem;font-style:italic;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
.pane header .monogram{min-width:1.5rem;height:1.5rem;line-height:1.5rem;
font-size:.68rem}
.paneacts{display:flex;gap:.25rem}
.paneacts button,.paneacts a{border:1px solid var(--line);background:#fff;
color:var(--ink);border-radius:4px;font-size:.72rem;line-height:1.25;
padding:.05rem .4rem;cursor:pointer;text-decoration:none}
.pane iframe{flex:1;border:0;width:100%;min-height:0;border-radius:0 0 5px 5px}
.pane.focus{outline:3px solid var(--role);outline-offset:-1px}
.deckmain.zoom .pane{display:none}
.deckmain.zoom .pane.zoomed{display:flex;grid-column:1/-1;grid-row:1/-1}
.wizard{border-left:1px solid var(--line);background:#fff;overflow-y:auto;
padding:.8rem .9rem;min-height:0}
.wizard h2{margin:.1rem 0 .3rem;font-size:1rem}
.wizlegend{display:flex;gap:.3rem;flex-wrap:wrap;margin:.4rem 0 .7rem}
.wizlegend span{font-size:.66rem;font-weight:700;border-radius:5px;
padding:.1rem .4rem;background:var(--roletint);color:var(--role)}
.wizstep{display:none;border-left:4px solid var(--role);
background:var(--roletint);border-radius:6px;padding:.65rem .8rem;
font-size:.86rem}
.wizstep.on{display:block}
.wizstep .rolechip{display:inline-block;font-weight:800;font-size:.7rem;
letter-spacing:.04em;color:var(--role);margin-bottom:.25rem}
.wizstep h3{margin:.1rem 0 .35rem;font-size:.92rem}
.wizstep .succ{margin:.45rem 0 0;font-size:.8rem}
.wizstep .succ b{color:var(--ok)}
.wizstep .learn{margin:.35rem 0 0;font-size:.8rem;color:var(--ink2);
font-style:italic}
.wizctl{display:flex;align-items:center;gap:.6rem;margin:.7rem 0 0}
.wizctl button{padding:.35rem .8rem;font-size:.84rem}
.wizctl button:disabled{opacity:.4;cursor:default}
#wizprog{font-size:.78rem;color:var(--ink2)}
@media(max-width:999px){
body.deckbody{overflow:auto;height:auto}
.deckgrid{display:flex;flex-direction:column-reverse}
.deckmain{grid-template-columns:1fr}
.pane{min-height:22rem}
.wizard{border-left:0;border-bottom:2px solid var(--line)}
}
"""
# The guided first watch. Each step: (role_id, title, do_html, success_html,
# learn_html). The step card wears the role's colors; the matching pane glows.
WIZARD_STEPS: list[tuple[str, str, str, str, str]] = [
("newcomer", "Welcome to the deck",
"Six panes, six roles, all live at the same time — a real crew works in "
"parallel, and so does this deck. The colors ARE the roles; this wizard "
"wears the color of whoever you are acting as, and that role's pane "
"glows. Nothing you do here can sign or change anything — every pane is "
"read-only evidence.",
"You can point at each pane and say what its role is for (the italic "
"line in each pane header helps).",
"One system, six distinct jobs — never one blurred job."),
("operator", "Morning watch: probe everything",
"Act as the <strong>Operator</strong> (green pane): press "
"<strong>«Probe now»</strong> on the liveness board. Watch the rows fill "
"with live facts. Find the <code>log head</code> row and read its "
"<code>tree_size</code> — that number is the public transparency log "
"answering you, right now.",
"Every service row says <b>alive</b> with observed facts and latency; "
"the repo rows show HEAD commits.",
"Liveness is checked on demand, never assumed — and never silently in "
"the background."),
("operator", "Read the wallet's own pulse",
"Still in green: scroll to <strong>Custody latch</strong> — it says "
"<em>unlatched</em>, and the panel explains what the latch would do. "
"Then <strong>Recorded history</strong>: this demo wallet carries "
"<em>1 incident</em> and <em>1 refusal receipt</em> on file. You will "
"meet both in the next steps.",
"You found the latch state and the two counters without leaving the "
"green pane.",
"The operator reads state from evidence panels, not from memory."),
("proposer", "Live the proposer: find your request",
"Switch hats — <strong>amber pane</strong>. One signing request sits in "
"the queue, <em>awaiting device</em>. Read its <strong>payload "
"fingerprint</strong>: that is the SHA-256 of the exact bytes the "
"offline signer would sign — and nothing else.",
"You can quote the first characters of the fingerprint of what would be "
"signed.",
"A proposal is precise, or it is nothing."),
("proposer", "Read a refusal like a to-do list",
"Still amber: open <strong>Incidents (refusals)</strong> from the "
"station's instrument links. The demo refusal says "
"<code>POLICY_DENIED</code>, missing <em>allowlisted destination</em>, "
"with a <code>remediation</code> naming exactly what would make the "
"same request succeed.",
"You can say, in one sentence, what the proposer would fix before "
"retrying.",
"warden never says a bare «no» — every refusal is machine-readable "
"instructions."),
("quorum", "Sit on the bench: find the dissenter",
"Now the <strong>indigo pane</strong>. Count the seats: four, each "
"built from a different verified codebase. The demo incident (you saw "
"its counter in step 3) records that <code>risc0</code> answered "
"INVALID while the other three said OK — in a real wallet, that single "
"dissent freezes custody on the spot.",
"You found all four seats and can name the dissenting member.",
"One honest dissenter beats three comfortable agreements — that is the "
"whole bench."),
("cryptographer", "Recompute — never trust",
"The <strong>purple pane</strong>: click <strong>«load the sample "
"evidence»</strong>, then <strong>Verify (read-only)</strong>. The "
"deployed verifier re-checks the signatures and the log inclusion in "
"front of you. Then delete one character from the receipt box and "
"verify again — watch it refuse, loudly.",
"First run: <b>ACCEPTED</b> with a diagnostics list. Broken run: "
"REJECTED, and the diagnostics name the exact failing check.",
"You never trusted this page — you recomputed it. That is the "
"cryptographer's entire posture."),
("architect", "Zoom out: map versus territory",
"The <strong>slate pane</strong>: read the <strong>drift "
"tripwire</strong> — it just compared the two renderings of the estate "
"map, name by name, and they agree. Then open the <strong>Estate "
"map</strong> link and find this wallet's place among the repos, "
"services, and the two self-referential loops.",
"You can say where warden sits on the map, and what the tripwire would "
"catch.",
"The map is recomputed, never remembered — or it lies."),
("operator", "The handoff lap — the team in one incident",
"Watch the whole crew move once, in your head, across the panes: the "
"<em>proposer</em> escalates a refusal → the <em>operator</em> "
"investigates and probes → the <em>bench</em> defends its dissent → "
"the <em>cryptographer</em> re-verifies the evidence → the "
"<em>architect</em> records what changed. Five roles touched one "
"incident — and not one of them did another's job.",
"You can retell the lap naming who hands what to whom.",
"Teamwork here is handoffs between distinct roles — never a blur."),
("newcomer", "Graduation",
"Pick the station that felt most like you and open it full-size (the "
"↗ in its pane header). Read its Mission, its Duties — every one a "
"real command — and its «This station never…» list; the never-list is "
"the fastest way to understand a role. When you are ready for a real "
"wallet: <code>pacta wallet init</code>.",
"You have a station — and you know what it never does.",
"If any page confused you on this watch, that is a bug in the page, "
"not in you. Report it — that is the newcomer's superpower."),
]
def render_deck(wallet_dir: str) -> str:
"""The full deck document: bar, pane grid, wizard rail, layout JS."""
demo_badge = (' <span class="pill warn">DEMO WALLET — custody-inert</span>'
if "DEMO" in wallet_dir else "")
panes = "".join(
f'<section class="pane" data-role="{s["id"]}" '
f'style="--role:{s["hue"]};--roletint:{s["tint"]}">'
f'<header><span class="monogram">{s["monogram"]}</span>'
f'<strong>{esc(s["name"])}</strong>'
f'<span class="q">{esc(s["question"])}</span>'
f'<span class="paneacts">'
f'<button data-act="reload" title="reload this pane">⟳</button>'
f'<button data-act="zoom" title="zoom this pane (tmux-style)">⤢</button>'
f'<a href="/station/{s["id"]}" target="_top" title="open full page">↗</a>'
f'</span></header>'
f'<iframe src="/station/{s["id"]}?pane=1" loading="lazy" '
f'title="{esc(s["name"])} station"></iframe>'
f'</section>'
for s in STATIONS)
legend = "".join(
f'<span style="--role:{s["hue"]};--roletint:{s["tint"]}">'
f'{s["monogram"]} {esc(s["name"])}</span>'
for s in STATIONS)
role_by_id = {s["id"]: s for s in STATIONS}
steps = "".join(
f'<div class="wizstep" data-role="{role_id}" '
f'style="--role:{role_by_id[role_id]["hue"]};'
f'--roletint:{role_by_id[role_id]["tint"]}">'
f'<span class="rolechip">{role_by_id[role_id]["monogram"]} · YOU ARE THE '
f'{esc(role_by_id[role_id]["name"]).upper()}</span>'
f'<h3>{title}</h3>'
f'<div>{do}</div>'
f'<p class="succ"><b>Success looks like:</b> {success}</p>'
f'<p class="learn">{learn}</p>'
f'</div>'
for role_id, title, do, success, learn in WIZARD_STEPS)
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>warden deck — all stations live</title>"
f"<style>{STYLE}{_DECK_EXTRA_STYLE}</style></head>"
"<body class='deckbody'>"
"<div class='deckbar'><strong>warden deck</strong>"
"<span>six stations, live in parallel</span>"
"<a href='/'>← Bridge</a>"
"<span class='pill warn'>READ-ONLY</span>"
f"{demo_badge}"
f"<span class='mono'>{esc(wallet_dir)}</span></div>"
"<div class='deckgrid'>"
f"<div class='deckmain'>{panes}</div>"
"<aside class='wizard'>"
"<h2>The wizard — your first watch</h2>"
"<p class='plain' style='font-size:.8rem'>A guided lap through every "
"role's real actions, on this live demo wallet. Each step wears the "
"color of the role you are living, and that pane glows.</p>"
f"<div class='wizlegend'>{legend}</div>"
f"{steps}"
"<div class='wizctl'>"
"<button id='wizprev'>← Back</button>"
"<button id='wiznext'>Next →</button>"
"<span id='wizprog'></span></div>"
"<p class='muted' style='margin-top:.8rem;font-size:.74rem'>Pane "
"controls, tmux-style: ⟳ reload · ⤢ zoom one pane · ↗ open the full "
"station page. Your step is remembered for this browser session.</p>"
"</aside></div>"
"<script>"
"(function(){"
"var panes=[].slice.call(document.querySelectorAll('.pane'));"
"var main=document.querySelector('.deckmain');"
"[].forEach.call(document.querySelectorAll('[data-act=reload]'),function(b){"
"b.onclick=function(){var f=b.closest('.pane').querySelector('iframe');"
"f.src=f.src;};});"
"[].forEach.call(document.querySelectorAll('[data-act=zoom]'),function(b){"
"b.onclick=function(){var p=b.closest('.pane');"
"var was=p.classList.contains('zoomed');"
"panes.forEach(function(x){x.classList.remove('zoomed');});"
"main.classList.toggle('zoom',!was);"
"if(!was){p.classList.add('zoomed');}};});"
"var steps=[].slice.call(document.querySelectorAll('.wizstep'));"
"var prev=document.getElementById('wizprev');"
"var next=document.getElementById('wiznext');"
"var prog=document.getElementById('wizprog');"
"var i=parseInt(sessionStorage.getItem('warden-wiz')||'0',10)||0;"
"function show(n){i=Math.max(0,Math.min(steps.length-1,n));"
"sessionStorage.setItem('warden-wiz',String(i));"
"steps.forEach(function(s,k){s.classList.toggle('on',k===i);});"
"prog.textContent=(i+1)+' / '+steps.length;"
"var role=steps[i].getAttribute('data-role');"
"panes.forEach(function(p){"
"p.classList.toggle('focus',p.getAttribute('data-role')===role);});"
"prev.disabled=(i===0);"
"next.textContent=(i===steps.length-1)?'Start over':'Next →';}"
"prev.onclick=function(){show(i-1);};"
"next.onclick=function(){show(i===steps.length-1?0:i+1);};"
"show(i);"
"})();"
"</script></body></html>"
)

View file

@ -55,6 +55,7 @@ from pathlib import Path
from typing import Any, Callable
from .attestation import load_attestation
from .deck import render_deck
from .liveness import collect_liveness, render_liveness
from .stations import STATION_BY_ID, STATIONS, render_bridge, render_station
from .transparency import load_receipt, verify_receipt
@ -169,7 +170,8 @@ def _collect_drift() -> dict[str, Any]:
# page shell - two-row navigation (stations / instruments), lead on every view
# ---------------------------------------------------------------------------
_STATION_TABS = [("/", "Bridge", "the whole system at a glance")] + [
_STATION_TABS = [("/", "Bridge", "the whole system at a glance"),
("/deck", "Deck", "all stations live, side by side")] + [
(f"/station/{s['id']}", s["name"], sub) for s, sub in zip(STATIONS, [
"ask for signatures", "four seats, one answer each",
"liveness + latch recovery", "recompute everything",
@ -224,6 +226,68 @@ def _tabs(items: list[tuple[str, str, str]], active: str) -> str:
for href, label, sub in items)
def _pane_shell(title: str, body: str) -> str:
"""Chrome-stripped render for deck panes: same content, no h1/banner/nav.
A tiny script keeps navigation inside the pane (every same-origin link
and form submit re-carries ?pane=1), so a pane behaves like a tmux pane:
an independent, self-contained viewport onto the cockpit.
"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>{_esc(title)} — pane</title>"
f"<style>{STYLE} body{{max-width:none;padding:.6rem .8rem 2.2rem}}</style>"
"</head><body>"
f"{body}"
"<div class='prov'>READ-ONLY pane — part of the "
"<a href='/deck' target='_top'>deck</a>. Links stay inside this pane; "
"use the pane header's ↗ for the full page.</div>"
"<script>(function(){"
"document.addEventListener('click',function(e){"
"var a=e.target.closest('a');if(!a||a.target==='_top'){return;}"
"try{var u=new URL(a.getAttribute('href'),location.href);"
"if(u.origin===location.origin&&!u.searchParams.has('pane')){"
"u.searchParams.set('pane','1');a.href=u.toString();}}catch(err){}});"
"document.addEventListener('submit',function(e){"
"try{var f=e.target;"
"var u=new URL(f.getAttribute('action')||location.href,location.href);"
"u.searchParams.set('pane','1');f.action=u.toString();}catch(err){}});"
"})();</script>"
"</body></html>")
def _load_sample_evidence() -> tuple[dict[str, str] | None, str]:
"""Pre-fill the inspector from examples/wallet-evidence (read-only)."""
root = Path(__file__).resolve().parents[2] / "examples" / "wallet-evidence"
attestations = sorted(root.glob("*.attestation.json"))
receipts = sorted(root.glob("*.receipt.json"))
key = root / "log.pub"
fallback_note = (
"<div class='panel'><p class='empty'>No sample evidence found on this "
"machine (expected under <code>examples/wallet-evidence/</code>). Fetch "
"real evidence from the live log instead: <code>pacta log-fetch --url "
"https://ltl.zkdefi.org --component dalek-ed25519-verified</code>, then "
"paste the two files and the log's <code>log.pub</code>.</p></div>")
if not (attestations and receipts and key.exists()):
return None, fallback_note
by_stem = {p.name.removesuffix(".attestation.json"): p for p in attestations}
for rec in receipts:
stem = rec.name.removesuffix(".receipt.json")
if stem in by_stem:
note = (
"<div class='panel'><p class='plain'>"
"<span class='pill ok'>sample loaded</span> evidence for "
f"<code>{_esc(stem)}</code> is pre-filled below — press «Verify "
"(read-only)» to watch the deployed verifier run. Then delete one "
"character from the receipt box and verify again: watch it refuse, "
"and read which check failed.</p></div>")
return ({"attestation": by_stem[stem].read_text(encoding="utf-8"),
"receipt": rec.read_text(encoding="utf-8"),
"pubkey": key.read_text(encoding="utf-8")}, note)
return None, fallback_note
def _page(title: str, active: str, body: str, wallet_dir: str) -> str:
nav = (f'<div class="navrow"><span class="navtag">STATIONS</span>'
f'{_tabs(_STATION_TABS, active)}</div>'
@ -537,7 +601,10 @@ def render_inspect(result: dict[str, Any] | None,
"duration of the check, and the wallet directory is never written.</li>"
"<li><strong>If the input is malformed</strong>, the page shows FAILED TO "
"VERIFY rather than guessing.</li></ul>")
+ "<form method='post' action='/inspect'>"
+ "<p><a class='btnlink' href='/inspect?sample=1'>Load the sample evidence</a> "
"<span class='muted'>from <code>examples/wallet-evidence/</code> — it fills the "
"three boxes below so you can watch a real verification.</span></p>"
"<form method='post' action='/inspect'>"
f"<p><strong>attestation.json</strong> <span class='muted'>— the signed verification statement</span><br>"
f"<textarea name='attestation'>{_esc(d.get('attestation', ''))}</textarea></p>"
f"<p><strong>receipt.json</strong> <span class='muted'>— the log's proof that the statement is recorded</span><br>"
@ -573,6 +640,8 @@ def _bridge_strip(posture: dict[str, Any], airgap: dict[str, Any]) -> str:
f"<span class='chip'>queue <b>{pending} awaiting device</b></span>"
"<span class='chip'>liveness — <a href='/station/operator?probe=1'>probe from "
"the Operator station</a></span>"
"<span class='chip'><a href='/deck'><b>Open the deck →</b></a> all six "
"stations live, side by side, with the guided wizard</span>"
"</div>"
+ _provenance("Wallet.posture() + airgap listing (liveness only on demand)")
)
@ -922,29 +991,42 @@ def make_handler(wallet_dir: Path):
parsed = urllib.parse.urlparse(self.path)
route = parsed.path
query = urllib.parse.parse_qs(parsed.query)
pane = query.get("pane", ["0"])[0] == "1"
wd = str(wallet_dir)
def page(title: str, active: str, body: str, status: int = 200) -> None:
self._send(_pane_shell(title, body) if pane
else _page(title, active, body, wd), status)
if route == "/":
wallet = self._wallet()
posture = collect("Wallet.posture()", wallet.posture)
airgap = collect_airgap(wallet)
body = render_bridge(_bridge_strip(posture, airgap),
_bridge_live(posture, airgap))
self._send(_page("bridge", "/", body, wd))
page("bridge", "/", body)
elif route == "/deck":
self._send(render_deck(wd))
elif route == "/posture":
wallet = self._wallet()
body = render_posture(collect("Wallet.posture()", wallet.posture))
self._send(_page("posture", "/posture", body, wd))
page("posture", "/posture", body)
elif route == "/queue":
body = render_queue(collect_airgap(self._wallet()))
self._send(_page("signature queue", "/queue", body, wd))
page("signature queue", "/queue",
render_queue(collect_airgap(self._wallet())))
elif route == "/incidents":
wallet = self._wallet()
body = render_incidents(collect_incidents(wallet), collect_refusals(wallet))
self._send(_page("incidents", "/incidents", body, wd))
page("incidents", "/incidents",
render_incidents(collect_incidents(wallet),
collect_refusals(wallet)))
elif route == "/inspect":
self._send(_page("receipt inspector", "/inspect", render_inspect(None), wd))
defaults, note = (None, "")
if query.get("sample", ["0"])[0] == "1":
defaults, note = _load_sample_evidence()
page("receipt inspector", "/inspect",
note + render_inspect(None, defaults))
elif route == "/guide":
self._send(_page("guide", "/guide", render_guide(), wd))
page("guide", "/guide", render_guide())
elif route == "/estate":
from .estateview import ESTATE_HTML
self._send(ESTATE_HTML + _ESTATE_BACK_CHIP)
@ -952,22 +1034,23 @@ def make_handler(wallet_dir: Path):
station_id = route.removeprefix("/station/")
station = STATION_BY_ID.get(station_id)
if station is None:
self._send(_page("not found", "",
"<div class='panel bad'>No such station. The "
"STATIONS row above lists the whole crew.</div>",
wd), 404)
page("not found", "",
"<div class='panel bad'>No such station. The STATIONS "
"row above lists the whole crew.</div>", 404)
return
probe = query.get("probe", ["0"])[0] == "1"
body = render_station(station,
self._station_embeds(station_id, probe))
self._send(_page(f"{station['name']} station", route, body, wd))
page(f"{station['name']} station", route, body)
else:
self._send(_page("not found", "",
"<div class='panel bad'>No such view. The tabs above list "
"everything this cockpit can show.</div>", wd), 404)
page("not found", "",
"<div class='panel bad'>No such view. The tabs above list "
"everything this cockpit can show.</div>", 404)
def do_POST(self) -> None: # noqa: N802
route = urllib.parse.urlparse(self.path).path
parsed = urllib.parse.urlparse(self.path)
route = parsed.path
pane = urllib.parse.parse_qs(parsed.query).get("pane", ["0"])[0] == "1"
if route != "/inspect":
self._send("<div class='panel bad'>No such action.</div>", 404)
return
@ -975,8 +1058,10 @@ def make_handler(wallet_dir: Path):
form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
fields = {k: form.get(k, [""])[0] for k in ("attestation", "receipt", "pubkey")}
result = inspect_receipt(fields["attestation"], fields["receipt"], fields["pubkey"])
self._send(_page("receipt inspector", "/inspect",
render_inspect(result, fields), str(wallet_dir)))
body = render_inspect(result, fields)
self._send(_pane_shell("receipt inspector", body) if pane
else _page("receipt inspector", "/inspect", body,
str(wallet_dir)))
def log_message(self, fmt: str, *args: Any) -> None: # quiet
return

View file

@ -179,8 +179,9 @@ def test_server_routes_and_read_only_guarantee(tmp_path):
thread.start()
try:
routes = ("/", "/posture", "/queue", "/incidents", "/inspect", "/guide",
"/station/proposer", "/station/quorum", "/station/operator",
"/station/operator?probe=1", "/station/cryptographer",
"/deck", "/station/proposer", "/station/quorum",
"/station/operator", "/station/operator?probe=1",
"/station/operator?pane=1", "/station/cryptographer",
"/station/architect", "/station/newcomer")
for route in routes:
with urllib.request.urlopen(f"http://127.0.0.1:{port}{route}") as resp:
@ -406,6 +407,65 @@ def test_stations_are_distinct_roles(tmp_path):
thread.join(timeout=5)
def test_deck_serves_all_panes_and_wizard(tmp_path):
"""The deck: every role live in its own pane (tmux-style grid), plus the
color-camouflaged wizard rail that walks a newcomer through each role."""
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
status, body = _get(port, "/deck")
assert status == 200
for sid in STATION_IDS:
assert f'src="/station/{sid}?pane=1"' in body, f"deck missing pane: {sid}"
assert "The wizard" in body and "your first watch" in body
assert body.count('class="wizstep"') >= 8, "wizard lost its steps"
for sid in STATION_IDS:
assert f'data-role="{sid}"' in body, f"wizard never visits {sid}"
assert "Success looks like" in body # every step tells the learner when they're done
assert "YOU ARE THE" in body # the role-camouflage chip
assert "READ-ONLY" in body
finally:
server.shutdown()
thread.join(timeout=5)
def test_pane_mode_is_chromeless_but_labeled(tmp_path):
"""Panes are independent viewports: full station content, no page chrome,
still labeled read-only; links inside stay in pane mode via the shim."""
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
status, body = _get(port, "/station/operator?pane=1")
assert status == 200
assert 'class="navrow"' not in body # no nav chrome inside a pane
assert "warden custody cockpit" not in body # no page h1 either
assert "Probe now" in body # the actual station content
assert "READ-ONLY pane" in body # but the guarantee stays visible
assert "searchParams.set('pane','1')" in body # the stay-in-pane shim
status, body = _get(port, "/inspect?pane=1")
assert status == 200 and 'class="navrow"' not in body
assert "Verify (read-only)" in body
finally:
server.shutdown()
thread.join(timeout=5)
def test_inspect_sample_prefill(tmp_path):
evidence = Path("examples") / "wallet-evidence"
if not (evidence / "log.pub").exists():
pytest.skip("example wallet evidence not present")
wallet = _seal_wallet(tmp_path)
server, thread, port = _serve(wallet.dir)
try:
status, body = _get(port, "/inspect?sample=1")
assert status == 200
assert "sample loaded" in body
assert "BEGIN PUBLIC KEY" in body # log.pub really pre-filled
finally:
server.shutdown()
thread.join(timeout=5)
def test_operator_probe_is_explicit_and_live(tmp_path):
"""The liveness board never phones home on an ordinary page load; probes
run only on the operator's explicit demand, then show per-target rows."""