mirror of
https://github.com/saymrwulf/proof-aware-crypto-tooling-agent.git
synced 2026-09-03 19:53:43 +00:00
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:
parent
a18877612d
commit
15421ac4d0
5 changed files with 483 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
281
src/pacta/deck.py
Normal 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>"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue