seisin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -10
- package/package.json +7 -6
- package/src/cli.js +3 -2
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +7 -2
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +112 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +8 -2
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +150 -2
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +12 -3
- package/src/inspect.js +178 -5
- package/src/intake.js +215 -0
- package/src/keys.js +108 -6
- package/src/log.js +96 -2
- package/src/mcp.js +28 -10
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +29 -0
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +131 -21
- package/src/spool.js +98 -20
- package/src/srt.js +123 -225
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +19 -5
- package/ui/favicon.svg +1 -1
- package/ui/index.html +221 -28
package/ui/index.html
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>seisin console</title>
|
|
7
|
-
<!-- A
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%
|
|
7
|
+
<!-- A boundary, which is what the word is about: seisin is the legal term for who
|
|
8
|
+
actually holds a piece of LAND. The S is a property line splitting one plot between
|
|
9
|
+
two holders, and the filled side is held. One stroke and one fill so it survives at
|
|
10
|
+
16px, where a favicon actually lives. Also in ui/favicon.svg for anyone who wants
|
|
11
|
+
the file; inline here so the console needs no second route to serve it. -->
|
|
12
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%227%22%20fill%3D%22%231f2328%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5H26.5V5.5Z%22%20fill%3D%22%232da44e%22%2F%3E%3Cpath%20d%3D%22M16%205.5C8%205.5%208%2016%2016%2016S24%2026.5%2016%2026.5%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22round%22%2F%3E%3C%2Fsvg%3E">
|
|
13
13
|
<meta name="description" content="Which folders each agent writes, which keys it may read, and whose a path is when the answer is no.">
|
|
14
14
|
<meta name="color-scheme" content="light dark">
|
|
15
15
|
<style>
|
|
@@ -60,6 +60,8 @@ body{margin:0;background:var(--bg);color:var(--fg);
|
|
|
60
60
|
.brand{padding:15px 14px 13px;border-bottom:1px solid var(--line);display:flex;flex-direction:column;gap:12px}
|
|
61
61
|
.brand b{font-size:15px;font-weight:600;letter-spacing:-.01em}
|
|
62
62
|
.brand small{font-size:11px;color:var(--mid);display:block;margin-top:1px}
|
|
63
|
+
.brand .id{display:flex;align-items:center;gap:10px}
|
|
64
|
+
.brand .mark{width:28px;height:28px;flex:none}
|
|
63
65
|
.theme{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
|
|
64
66
|
.theme button{flex:1;border:0;background:none;color:var(--mid);padding:4px 0;border-radius:4px;
|
|
65
67
|
cursor:pointer;font:inherit;font-size:12px}
|
|
@@ -87,6 +89,15 @@ button.btn.warn{border-color:var(--amber);color:var(--amber)}
|
|
|
87
89
|
button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(--blue);outline-offset:2px}
|
|
88
90
|
|
|
89
91
|
.cards{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))}
|
|
92
|
+
/* Time window: quick presets plus a free "from". Filters what comes from the log. */
|
|
93
|
+
.range{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;color:var(--mid)}
|
|
94
|
+
.range .seg{display:flex;gap:2px;background:var(--hover);border-radius:6px;padding:2px}
|
|
95
|
+
.range .seg button{border:0;background:none;color:var(--mid);padding:4px 10px;border-radius:4px;
|
|
96
|
+
cursor:pointer;font:inherit;font-size:12px}
|
|
97
|
+
.range .seg button.on{background:var(--panel);color:var(--fg);box-shadow:0 1px 2px #0002}
|
|
98
|
+
.range input{font:inherit;font-size:12px;padding:4px 8px;border:1px solid var(--line2);
|
|
99
|
+
border-radius:6px;background:var(--bg);color:var(--fg)}
|
|
100
|
+
.range input.on{border-color:var(--fg)}
|
|
90
101
|
.card{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--line2);
|
|
91
102
|
border-radius:8px;padding:14px 16px;min-height:96px;display:flex;flex-direction:column}
|
|
92
103
|
.card.g{border-left-color:var(--green)} .card.a{border-left-color:var(--amber)} .card.b{border-left-color:var(--blue)}
|
|
@@ -145,6 +156,9 @@ button:focus-visible,a:focus-visible,select:focus-visible{outline:2px solid var(
|
|
|
145
156
|
justify-content:space-between;gap:12px}
|
|
146
157
|
.box>header h2{font-size:13px;font-weight:600;margin:0}
|
|
147
158
|
.box>header .note{font-size:11px;color:var(--dim)}
|
|
159
|
+
.box>header .bulk{margin-left:auto;display:flex;gap:6px;align-items:center}
|
|
160
|
+
.box>header .bulk[hidden]{display:none}
|
|
161
|
+
.box>header .bulk input{font-size:12px;padding:4px 6px;min-width:180px}
|
|
148
162
|
|
|
149
163
|
.scrollx{overflow-x:auto}
|
|
150
164
|
table{width:100%;border-collapse:collapse;font-size:13px;min-width:720px}
|
|
@@ -255,8 +269,11 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
255
269
|
|
|
256
270
|
@media (max-width:980px){.two{grid-template-columns:1fr}}
|
|
257
271
|
@media (max-width:720px){
|
|
258
|
-
|
|
259
|
-
|
|
272
|
+
/* minmax(0,1fr), not 1fr: a bare 1fr never shrinks below its widest child's
|
|
273
|
+
min-content, and the nav's unwrapped links made the whole page 793px wide on a
|
|
274
|
+
375px phone. The tabs scroll inside .list instead. */
|
|
275
|
+
.frame{grid-template-columns:minmax(0,1fr)}
|
|
276
|
+
.side{border-right:0;border-bottom:1px solid var(--line);min-width:0}
|
|
260
277
|
.side .list{display:flex;overflow-x:auto;padding:6px 8px}
|
|
261
278
|
.side .cap{display:none}
|
|
262
279
|
.side a{border-left:0;border-bottom:2px solid transparent;white-space:nowrap}
|
|
@@ -268,6 +285,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
268
285
|
/* Requests. A queue nobody opens is not human-in-the-loop, so it looks like
|
|
269
286
|
something waiting rather than another table of facts. */
|
|
270
287
|
.req{display:flex;gap:14px;align-items:flex-start;padding:14px 16px;border-top:1px solid var(--line)}
|
|
288
|
+
.req.stale .who{opacity:.6}
|
|
271
289
|
.req:first-child{border-top:0}
|
|
272
290
|
.req .who{flex:1;min-width:0}
|
|
273
291
|
.req .who b{font-weight:600}
|
|
@@ -286,7 +304,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
286
304
|
|
|
287
305
|
<nav class="side">
|
|
288
306
|
<div class="brand">
|
|
289
|
-
<div><b>seisin</b><small id="source">folders and keys, per agent</small></div>
|
|
307
|
+
<div class="id"><svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="7" fill="var(--fg)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5H26.5V5.5Z" fill="var(--green)"/><path d="M16 5.5C8 5.5 8 16 16 16S24 26.5 16 26.5" fill="none" stroke="var(--bg)" stroke-width="2.8" stroke-linecap="round"/></svg><div><b>seisin</b><small id="source">folders and keys, per agent</small></div></div>
|
|
290
308
|
<div class="theme" role="group" aria-label="Theme">
|
|
291
309
|
<button type="button" id="t-light">light</button>
|
|
292
310
|
<button type="button" id="t-dark">dark</button>
|
|
@@ -330,6 +348,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
330
348
|
Each one is a link to the screen that explains it. A number you cannot
|
|
331
349
|
act on is decoration; a number that takes you to the thing it counts
|
|
332
350
|
is the top of a path. -->
|
|
351
|
+
<div class="range" id="range" hidden>
|
|
352
|
+
<span>Window</span>
|
|
353
|
+
<div class="seg" role="group" aria-label="Time window">
|
|
354
|
+
<button type="button" data-r="1h">1h</button>
|
|
355
|
+
<button type="button" data-r="4h">4h</button>
|
|
356
|
+
<button type="button" data-r="24h">24h</button>
|
|
357
|
+
<button type="button" data-r="7d">7d</button>
|
|
358
|
+
<button type="button" data-r="30d">30d</button>
|
|
359
|
+
<button type="button" data-r="all">All</button>
|
|
360
|
+
</div>
|
|
361
|
+
<label>from <input type="datetime-local" id="range-from" aria-label="From"></label>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
333
364
|
<div class="cards" id="kpis">
|
|
334
365
|
<a class="card kpi" href="#requests" id="k-wait">
|
|
335
366
|
<h2>waiting on you</h2><div class="n" id="c-wait">0</div><div class="s" id="c-wait-s">nothing to decide</div></a>
|
|
@@ -387,6 +418,10 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
387
418
|
<header>
|
|
388
419
|
<h2>Pending decisions</h2>
|
|
389
420
|
<span class="note">granting edits seisin.toml · here or in the terminal, never through MCP</span>
|
|
421
|
+
<span class="bulk" id="bulk" hidden>
|
|
422
|
+
<input id="why-all" placeholder="why, for all of them" aria-label="reason for declining all">
|
|
423
|
+
<button class="btn warn" id="decline-all" type="button">Decline all</button>
|
|
424
|
+
</span>
|
|
390
425
|
</header>
|
|
391
426
|
<div id="reqs"></div>
|
|
392
427
|
</div>
|
|
@@ -456,7 +491,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
456
491
|
<div class="foot">
|
|
457
492
|
<b id="foot-mode">Console preview.</b> <span id="foot-text">Opened as a file, this page edits a policy in memory and shows what
|
|
458
493
|
would be generated. Copy the <b>seisin.toml</b> above to keep a change.</span><br>
|
|
459
|
-
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.
|
|
494
|
+
The JSON matches <b>@anthropic-ai/sandbox-runtime 0.0.77</b>, whose schema the test suite exercises against the real binary.
|
|
460
495
|
</div>
|
|
461
496
|
</main>
|
|
462
497
|
</div>
|
|
@@ -668,14 +703,15 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
668
703
|
}
|
|
669
704
|
L.push("{");
|
|
670
705
|
L.push(' <span class="k">"network"</span>: {');
|
|
671
|
-
|
|
706
|
+
var lp=(r.localPorts||[]).map(function(n){return '"localhost:'+Number(n)+'"';});
|
|
707
|
+
L.push(' "allowedDomains": ["github.com"'+(lp.length?', <span class="g">'+lp.join(', ')+'</span>':'')+']'+(lp.length?', <span class="c">// local_ports, through the proxy</span>':','));
|
|
672
708
|
L.push(' "deniedDomains": [], "allowUnixSockets": [],');
|
|
673
|
-
L.push(' "allowLocalBinding": false');
|
|
709
|
+
L.push(' "allowLocalBinding": '+(r.localBinding?'<span class="d">true</span> <span class="c">// can reach every localhost port (macOS)</span>':'false'));
|
|
674
710
|
L.push(" },");
|
|
675
711
|
L.push(' <span class="k">"filesystem"</span>: {');
|
|
676
712
|
// KEYDIR comes from seisin.toml, which is a file the console may be
|
|
677
713
|
// pointed at without having written it. Unescaped it was HTML injection
|
|
678
|
-
// into a page that
|
|
714
|
+
// into a page that holds the run's token — and that token approves grants.
|
|
679
715
|
L.push(' <span class="d">"denyRead"</span>: ["'+esc(KEYDIR)+'"],');
|
|
680
716
|
if(r.keys.length){
|
|
681
717
|
L.push(' <span class="g">"allowRead"</span>: [');
|
|
@@ -690,8 +726,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
690
726
|
});
|
|
691
727
|
else L.push(' <span class="c">// no territory</span>');
|
|
692
728
|
L.push(" ],");
|
|
693
|
-
|
|
694
|
-
|
|
729
|
+
if((r.neverWrites||[]).length){
|
|
730
|
+
L.push(' <span class="d">"denyWrite"</span>: [ <span class="c">// never_writes</span>');
|
|
731
|
+
r.neverWrites.forEach(function(p,i){ L.push(' "'+esc(p.replace(/\/\*\*$/,""))+'"'+(i<r.neverWrites.length-1?",":"")); });
|
|
732
|
+
L.push(" ]");
|
|
733
|
+
} else L.push(' "denyWrite": []');
|
|
734
|
+
L.push(" }"+(r.trustd?",":""));
|
|
735
|
+
if(r.trustd) L.push(' <span class="d">"enableWeakerNetworkIsolation"</span>: true <span class="c">// trustd = true</span>');
|
|
695
736
|
L.push("}");
|
|
696
737
|
document.getElementById("cfg").innerHTML=L.join("\n");
|
|
697
738
|
}
|
|
@@ -851,7 +892,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
851
892
|
"somebody claims them.";
|
|
852
893
|
document.getElementById("ca-lede").innerHTML = lede;
|
|
853
894
|
var win = document.getElementById("dn-window");
|
|
854
|
-
if (win) win.textContent = f.total.toLocaleString() + " denials \u00b7 " +
|
|
895
|
+
if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
|
|
855
896
|
(f.distinct || f.causes.length).toLocaleString() + " distinct paths";
|
|
856
897
|
|
|
857
898
|
var max = top.times || 1;
|
|
@@ -978,7 +1019,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
978
1019
|
set("k-open", off ? "alarm" : "calm", off,
|
|
979
1020
|
off ? (off === 1 ? "role nothing is enforcing" : "roles nothing is enforcing") : "every role confined");
|
|
980
1021
|
set("k-ref", f.total ? "live" : "calm", f.total.toLocaleString(),
|
|
981
|
-
f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied")
|
|
1022
|
+
(f.total ? (f.distinct || f.causes.length).toLocaleString() + " distinct paths" : "nothing was denied") +
|
|
1023
|
+
(RANGE === "all" ? "" : " \u00b7 " + rangeLabel()));
|
|
982
1024
|
// The shape, not the size: one name at 74% is a tooling problem, the same
|
|
983
1025
|
// volume spread thin is a territory question, so the caption carries the name.
|
|
984
1026
|
set("k-conc", fam && fam.share >= 0.4 ? "live" : "calm",
|
|
@@ -1014,11 +1056,13 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1014
1056
|
host.innerHTML = REQS.map(function(r, i){
|
|
1015
1057
|
var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
|
|
1016
1058
|
var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
|
|
1017
|
-
|
|
1059
|
+
// Marked, not moved: Approve and Decline post this row's number.
|
|
1060
|
+
var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
|
|
1061
|
+
return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
|
|
1018
1062
|
'<div class="who">' +
|
|
1019
1063
|
"<div><b>" + esc(r.role) + "</b> wants to " + esc(r.action) +
|
|
1020
1064
|
' <span class="path">' + esc(r.grant) + "</span>" + owners + "</div>" +
|
|
1021
|
-
'<div class="sub">first asked over ' + esc(r.target) + times + "</div>" +
|
|
1065
|
+
'<div class="sub">first asked over ' + esc(r.target) + times + stale + "</div>" +
|
|
1022
1066
|
"</div>" +
|
|
1023
1067
|
'<div class="act">' +
|
|
1024
1068
|
'<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
|
|
@@ -1031,6 +1075,48 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1031
1075
|
host.querySelectorAll("button[data-n]").forEach(function(b){
|
|
1032
1076
|
b.addEventListener("click", function(){ act(b); });
|
|
1033
1077
|
});
|
|
1078
|
+
drawBulk();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Decline every request on screen — two clicks, no dialog.
|
|
1083
|
+
*
|
|
1084
|
+
* There is no "approve all" on purpose: a grant is read one at a time. The
|
|
1085
|
+
* keys sent are the ones drawn now, so a request that arrives in between is
|
|
1086
|
+
* not refused unseen.
|
|
1087
|
+
*/
|
|
1088
|
+
function drawBulk(){
|
|
1089
|
+
var bulk = document.getElementById("bulk");
|
|
1090
|
+
var btn = document.getElementById("decline-all");
|
|
1091
|
+
bulk.hidden = REQS.length < 2;
|
|
1092
|
+
btn.disabled = false;
|
|
1093
|
+
btn.dataset.armed = "";
|
|
1094
|
+
btn.textContent = "Decline all (" + REQS.length + ")";
|
|
1095
|
+
btn.onclick = async function(){
|
|
1096
|
+
if (!btn.dataset.armed) {
|
|
1097
|
+
btn.dataset.armed = "1";
|
|
1098
|
+
btn.textContent = "Click again to decline " + REQS.length;
|
|
1099
|
+
setTimeout(function(){ if (btn.dataset.armed) { btn.dataset.armed = ""; btn.textContent = "Decline all (" + REQS.length + ")"; } }, 4000);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
btn.disabled = true;
|
|
1103
|
+
try {
|
|
1104
|
+
var r = await fetch("/api/decline-all", {
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1107
|
+
body: JSON.stringify({ keys: REQS.map(function(q){ return q.key; }),
|
|
1108
|
+
reason: (document.getElementById("why-all") || {}).value || "declined in bulk from the console" })
|
|
1109
|
+
});
|
|
1110
|
+
var out = await r.json();
|
|
1111
|
+
if (!r.ok) { var err = new Error(out.error || "refused"); err.status = r.status; throw err; }
|
|
1112
|
+
await reload();
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
btn.disabled = false;
|
|
1115
|
+
btn.textContent = "Decline all (" + REQS.length + ")";
|
|
1116
|
+
var note = document.querySelector("#requests .note");
|
|
1117
|
+
if (note) { note.textContent = whyFailed(e, e.status); note.style.color = "var(--amber)"; }
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1034
1120
|
}
|
|
1035
1121
|
|
|
1036
1122
|
/**
|
|
@@ -1047,18 +1133,18 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
|
|
|
1047
1133
|
try {
|
|
1048
1134
|
var r = await fetch("/api/decide", {
|
|
1049
1135
|
method: "POST",
|
|
1050
|
-
headers: { "content-type": "application/json", "x-seisin-token":
|
|
1136
|
+
headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
|
|
1051
1137
|
body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
|
|
1052
1138
|
});
|
|
1053
1139
|
var out = await r.json();
|
|
1054
|
-
if (!r.ok)
|
|
1140
|
+
if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }
|
|
1055
1141
|
// Re-read rather than patch: the grant rewrote seisin.toml, so the
|
|
1056
1142
|
// territory on screen is stale too, not just the queue.
|
|
1057
1143
|
await reload();
|
|
1058
1144
|
} catch (e) {
|
|
1059
1145
|
row.querySelectorAll("button,input").forEach(function(el){ el.disabled = false; });
|
|
1060
1146
|
var s = row.querySelector(".sub");
|
|
1061
|
-
s.innerHTML = '<span style="color:var(--amber)">' + esc(e.
|
|
1147
|
+
s.innerHTML = '<span style="color:var(--amber)">' + esc(whyFailed(e, e.status)) + "</span>";
|
|
1062
1148
|
}
|
|
1063
1149
|
}
|
|
1064
1150
|
|
|
@@ -1085,16 +1171,112 @@ function draw(){
|
|
|
1085
1171
|
* you can look at before installing anything. What it must never do is show
|
|
1086
1172
|
* example data while looking live, so the label says which one you are in.
|
|
1087
1173
|
*/
|
|
1174
|
+
/**
|
|
1175
|
+
* This run's token, from the link `seisin ui` opened.
|
|
1176
|
+
*
|
|
1177
|
+
* It arrives in the fragment (#t=…), which the browser never sends to any
|
|
1178
|
+
* server, and it is moved into this tab's storage and wiped from the address
|
|
1179
|
+
* bar and the history entry at once. The server no longer puts it in the
|
|
1180
|
+
* page: anything that could GET "/" used to walk away with it.
|
|
1181
|
+
*/
|
|
1182
|
+
var TOKEN = (function(){
|
|
1183
|
+
var m = /(?:^#|&)t=([a-f0-9]+)/.exec(location.hash || "");
|
|
1184
|
+
try {
|
|
1185
|
+
if (m) { sessionStorage.setItem("seisin-token", m[1]); }
|
|
1186
|
+
var t = m ? m[1] : (sessionStorage.getItem("seisin-token") || "");
|
|
1187
|
+
if (m) history.replaceState(null, "", location.pathname + location.search);
|
|
1188
|
+
return t;
|
|
1189
|
+
} catch (_) { return m ? m[1] : ""; }
|
|
1190
|
+
})();
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* What to tell a person when a request to this server fails.
|
|
1194
|
+
*
|
|
1195
|
+
* "Failed to fetch" was all the browser said when the console had been
|
|
1196
|
+
* restarted under an open tab — the server was gone, or back with a new
|
|
1197
|
+
* token that this tab does not hold. Both have the same fix, and it is not
|
|
1198
|
+
* obvious from either message.
|
|
1199
|
+
*/
|
|
1200
|
+
function whyFailed(e, status){
|
|
1201
|
+
if (status === 403)
|
|
1202
|
+
return "this tab's link is stale — the console restarted. Open the new link `seisin ui` printed.";
|
|
1203
|
+
if (e && /network|fetch/i.test(String(e.message || e)))
|
|
1204
|
+
return "the console did not answer. If `seisin ui` restarted, open the new link it printed.";
|
|
1205
|
+
return (e && e.message) || "failed";
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/* ── time window ──────────────────────────────────────────────────────── */
|
|
1209
|
+
// "1h" … "30d", "all", or "from:<ISO>". Remembered per browser, as a convenience.
|
|
1210
|
+
var RANGE = "all";
|
|
1211
|
+
try { RANGE = localStorage.getItem("seisin.range") || "all"; } catch (_) {}
|
|
1212
|
+
var SPANS = { "1h": 36e5, "4h": 4 * 36e5, "24h": 24 * 36e5, "7d": 7 * 864e5, "30d": 30 * 864e5 };
|
|
1213
|
+
|
|
1214
|
+
// Worked out on every request, so "last 4h" keeps moving while the page is open.
|
|
1215
|
+
function sinceIso(){
|
|
1216
|
+
if (SPANS[RANGE]) return new Date(Date.now() - SPANS[RANGE]).toISOString();
|
|
1217
|
+
if (RANGE.indexOf("from:") === 0) return RANGE.slice(5);
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
function stateUrl(){
|
|
1221
|
+
var s = sinceIso();
|
|
1222
|
+
return "/api/state" + (s ? "?since=" + encodeURIComponent(s) : "");
|
|
1223
|
+
}
|
|
1224
|
+
function rangeLabel(){
|
|
1225
|
+
if (SPANS[RANGE]) return "last " + RANGE;
|
|
1226
|
+
if (RANGE.indexOf("from:") === 0) return "since " + new Date(RANGE.slice(5)).toLocaleString();
|
|
1227
|
+
return "all time";
|
|
1228
|
+
}
|
|
1229
|
+
function drawRange(){
|
|
1230
|
+
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1231
|
+
b.classList.toggle("on", b.dataset.r === RANGE);
|
|
1232
|
+
});
|
|
1233
|
+
var from = document.getElementById("range-from");
|
|
1234
|
+
from.classList.toggle("on", RANGE.indexOf("from:") === 0);
|
|
1235
|
+
if (RANGE.indexOf("from:") !== 0) from.value = "";
|
|
1236
|
+
}
|
|
1237
|
+
function setRange(r){
|
|
1238
|
+
RANGE = r;
|
|
1239
|
+
try { localStorage.setItem("seisin.range", r); } catch (_) {}
|
|
1240
|
+
drawRange();
|
|
1241
|
+
reload().catch(function(){ /* server went away: keep what is on screen */ });
|
|
1242
|
+
}
|
|
1243
|
+
document.querySelectorAll("#range .seg button").forEach(function(b){
|
|
1244
|
+
b.addEventListener("click", function(){ setRange(b.dataset.r); });
|
|
1245
|
+
});
|
|
1246
|
+
document.getElementById("range-from").addEventListener("change", function(e){
|
|
1247
|
+
var t = Date.parse(e.target.value); // datetime-local is local time, which Date.parse reads as such
|
|
1248
|
+
setRange(isNaN(t) ? "all" : "from:" + new Date(t).toISOString());
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1088
1251
|
async function boot(){
|
|
1089
|
-
let s = null;
|
|
1252
|
+
let s = null, stale = false;
|
|
1090
1253
|
try {
|
|
1091
|
-
const r = await fetch(
|
|
1254
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
1092
1255
|
if (r.ok) s = await r.json();
|
|
1256
|
+
// A live server that refuses this tab's token: `seisin ui` was restarted
|
|
1257
|
+
// and minted a new one. Falling back to the example here showed a policy
|
|
1258
|
+
// that is not the reader's, one F5 after a restart, with nothing saying why.
|
|
1259
|
+
stale = r.status === 403;
|
|
1093
1260
|
} catch (_) { /* opened as a file: stay in demo */ }
|
|
1094
1261
|
|
|
1095
1262
|
const label = document.getElementById("source");
|
|
1263
|
+
if (stale) {
|
|
1264
|
+
label.textContent = "link from an earlier run";
|
|
1265
|
+
label.style.color = "var(--amber)";
|
|
1266
|
+
document.querySelector("main").innerHTML =
|
|
1267
|
+
'<div class="box" style="padding:22px 24px;max-width:640px">' +
|
|
1268
|
+
'<h2 style="margin:0 0 8px;font-size:17px">This tab has the link from an earlier run</h2>' +
|
|
1269
|
+
'<p style="margin:0 0 8px">`seisin ui` was restarted, and every run gets a new token. ' +
|
|
1270
|
+
'Open the new link it printed in the terminal (it starts with ' +
|
|
1271
|
+
'<code>http://127.0.0.1:' + location.port + '/#t=</code>).</p>' +
|
|
1272
|
+
'<p style="margin:0;color:var(--mid)">Nothing here is shown until then, so this page never ' +
|
|
1273
|
+
'passes the example policy off as yours.</p></div>';
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1096
1276
|
if (!s || s.error) {
|
|
1097
|
-
label.textContent = "
|
|
1277
|
+
label.textContent = location.protocol.startsWith("http") && !TOKEN
|
|
1278
|
+
? "no token — open the link `seisin ui` printed · showing an example"
|
|
1279
|
+
: "example policy — not your repo";
|
|
1098
1280
|
label.style.color = "var(--amber)";
|
|
1099
1281
|
draw();
|
|
1100
1282
|
return;
|
|
@@ -1107,7 +1289,13 @@ function draw(){
|
|
|
1107
1289
|
writes: r.writes.slice(),
|
|
1108
1290
|
keys: r.keys.map(k => k.replace(/\.[^.]+$/, "")),
|
|
1109
1291
|
sandbox: true,
|
|
1110
|
-
blocks: r.blocks
|
|
1292
|
+
blocks: r.blocks,
|
|
1293
|
+
// The three role keys that change the profile, drawn from what the
|
|
1294
|
+
// server says rather than typed into this page (review of 2026-09-22, #5).
|
|
1295
|
+
neverWrites: r.neverWrites || [],
|
|
1296
|
+
localBinding: !!r.localBinding,
|
|
1297
|
+
localPorts: (r.localPorts || []).slice(),
|
|
1298
|
+
trustd: !!r.trustd
|
|
1111
1299
|
}));
|
|
1112
1300
|
LOG = fromServer(s.log);
|
|
1113
1301
|
REQS = s.requests || [];
|
|
@@ -1131,6 +1319,8 @@ function draw(){
|
|
|
1131
1319
|
// everything" against a real repo is not a preview. `dataset.live` is what `show()`
|
|
1132
1320
|
// reads, so the router never brings it back when you return to Roles.
|
|
1133
1321
|
document.getElementById("kill").dataset.live = "1";
|
|
1322
|
+
document.getElementById("range").hidden = false;
|
|
1323
|
+
drawRange();
|
|
1134
1324
|
document.getElementById("kill").hidden = true;
|
|
1135
1325
|
document.getElementById("toml-note").textContent = "the file on disk, as it stands";
|
|
1136
1326
|
document.getElementById("foot-mode").textContent = "Live.";
|
|
@@ -1227,7 +1417,7 @@ function route(){ return (location.hash || "#requests").slice(1); }
|
|
|
1227
1417
|
|
|
1228
1418
|
async function refreshLog(){
|
|
1229
1419
|
try {
|
|
1230
|
-
const r = await fetch(
|
|
1420
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
1231
1421
|
if (!r.ok) return;
|
|
1232
1422
|
const s = await r.json();
|
|
1233
1423
|
if (s.error) return;
|
|
@@ -1250,7 +1440,7 @@ function route(){ return (location.hash || "#requests").slice(1); }
|
|
|
1250
1440
|
|
|
1251
1441
|
/** Everything again, after a decision rewrote the policy on disk. */
|
|
1252
1442
|
async function reload(){
|
|
1253
|
-
const r = await fetch(
|
|
1443
|
+
const r = await fetch(stateUrl(), { cache: "no-store", headers: { "x-seisin-token": TOKEN } });
|
|
1254
1444
|
const s = await r.json();
|
|
1255
1445
|
if (s.error) return;
|
|
1256
1446
|
KEYDIR = (s.keyDirs && s.keyDirs[0]) || ".secrets";
|
|
@@ -1262,8 +1452,11 @@ function route(){ return (location.hash || "#requests").slice(1); }
|
|
|
1262
1452
|
}));
|
|
1263
1453
|
LOG = fromServer(s.log);
|
|
1264
1454
|
REQS = s.requests || [];
|
|
1455
|
+
CAUSES = s.causes || null;
|
|
1456
|
+
WALLS = s.walls || {};
|
|
1265
1457
|
ONDISK = s.toml || null;
|
|
1266
1458
|
draw();
|
|
1459
|
+
drawKpis();
|
|
1267
1460
|
}
|
|
1268
1461
|
|
|
1269
1462
|
boot();
|