seisin 0.3.0 → 0.4.2

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/src/uilink.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Where a running `seisin ui` leaves its live link, so it can be found again.
3
+ *
4
+ * The console's token never travels in an HTTP response — that was the whole
5
+ * point of the redesign in serve.js: a page that could GET `/` used to walk
6
+ * away with it. The cost was that a tab which lost the fragment had nowhere to
7
+ * recover the token from but the terminal that printed it once. When that
8
+ * terminal is gone, so is the link.
9
+ *
10
+ * This is the recovery channel that does not reopen the leak: the link (token
11
+ * and all) is written to a file only this user can read — under `$TMPDIR/snr/`,
12
+ * the same 0700 directory runs already keep their sockets and keys in — and
13
+ * never sent over the wire. `seisin ui` on a busy port reads it and reopens the
14
+ * live link instead of failing; `seisin ui --link` prints it. The token now
15
+ * rests on disk (0600) for the life of the run rather than only in memory, a
16
+ * bounded, local-user exposure, and it is removed when the run exits.
17
+ *
18
+ * The pid is stored so a file left by a crashed run is recognisable: a link
19
+ * whose writer is gone is stale, not a running console.
20
+ */
21
+ import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { runsRoot } from "./rundir.js";
24
+
25
+ function linkFile(port, base) {
26
+ return join(runsRoot(base), `ui-${port}.url`);
27
+ }
28
+
29
+ /** Records this run's live URL for the port it bound. */
30
+ export function writeUiLink(port, url, base) {
31
+ writeFileSync(linkFile(port, base), JSON.stringify({ url, pid: process.pid }) + "\n", { mode: 0o600 });
32
+ }
33
+
34
+ /**
35
+ * The live URL a `seisin ui` left for this port, with whether its writer is
36
+ * still alive — or null if there is no readable record.
37
+ */
38
+ export function readUiLink(port, base) {
39
+ let raw;
40
+ try { raw = readFileSync(linkFile(port, base), "utf8"); } catch { return null; }
41
+ let e;
42
+ try { e = JSON.parse(raw); } catch { return null; }
43
+ if (!e || typeof e.url !== "string") return null;
44
+ let alive = null;
45
+ if (typeof e.pid === "number") {
46
+ // Signal 0 tests for the process without touching it: it throws if gone.
47
+ try { process.kill(e.pid, 0); alive = true; } catch { alive = false; }
48
+ }
49
+ return { url: e.url, pid: e.pid ?? null, alive };
50
+ }
51
+
52
+ /** Removes the record. Called on exit, and when a stale one is found. */
53
+ export function clearUiLink(port, base) {
54
+ try { unlinkSync(linkFile(port, base)); } catch {}
55
+ }
package/ui/index.html CHANGED
@@ -320,7 +320,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
320
320
  <a href="#activity"><i>◷</i>Activity</a>
321
321
  <a href="#configfile"><i>⚙</i>Config file</a>
322
322
  <div class="cap">Docs</div>
323
- <a href="https://github.com/anthropic-experimental/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
323
+ <a href="https://github.com/anthropics/sandbox-runtime" target="_blank" rel="noopener"><i>↗</i>sandbox-runtime</a>
324
324
  </div>
325
325
  </nav>
326
326
 
@@ -522,6 +522,9 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
522
522
  ];
523
523
 
524
524
  function esc(s){return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
525
+ // For attribute values: the text an agent chose (a request's id is its path)
526
+ // must not be able to close the quote it sits in.
527
+ function escA(s){return esc(s).replace(/"/g,"&quot;").replace(/'/g,"&#39;");}
525
528
  function pad(n){return String(n).padStart(2,"0");}
526
529
 
527
530
  function theme(v){
@@ -886,10 +889,19 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
886
889
  person decides who owns them — a decision, not a count. Added after a
887
890
  field review pointed out that the percentage above was the console
888
891
  reading the log back to somebody who had already read it. */
892
+ /* Where each path stands against the policy NOW, not what the log wrote the day of the
893
+ refusal: that was read here once, and every grant made since kept its paths counted as
894
+ nobody's. Protected paths and ports are said apart, because they ask the opposite of a
895
+ decision \u2014 there is nothing to grant. */
896
+ var st = f.standing || {};
889
897
  if (f.unowned)
890
- lede += " <b>" + f.unowned + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
898
+ lede += " <b>" + f.unowned.toLocaleString() + "</b> of the " + (f.distinct || f.causes.length).toLocaleString() +
891
899
  " are on paths <b>no role owns</b> \u2014 a grant cannot settle those until " +
892
900
  "somebody claims them.";
901
+ var closed = (st.protected ? st.protected.paths : 0);
902
+ if (closed)
903
+ lede += " <b>" + closed.toLocaleString() + "</b> " + (closed === 1 ? "is" : "are") +
904
+ " protected on purpose \u2014 the boundary working, nothing to grant.";
893
905
  document.getElementById("ca-lede").innerHTML = lede;
894
906
  var win = document.getElementById("dn-window");
895
907
  if (win) win.textContent = rangeLabel() + " \u00b7 " + f.total.toLocaleString() + " denials \u00b7 " +
@@ -901,12 +913,16 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
901
913
  // A cause the policy no longer denies is history, not live. It is
902
914
  // shown, greyed, rather than dropped: it explains a number somebody may
903
915
  // remember from yesterday instead of silently changing it.
904
- var cold = c.stillDeclined === 0;
916
+ // `stillRefused` is what the server sends. This read `stillDeclined`, which it never
917
+ // did, so nothing was ever greyed.
918
+ var cold = c.stillRefused === 0;
905
919
  var who = c.roles.length === 1 ? c.roles[0] : c.roles.length + " roles";
906
920
  return '<div class="bar' + (cold ? ' cold' : '') + '">' +
907
921
  '<div class="lbl"><div class="p">' + esc(c.action) + ' ' + esc(c.target) + '</div>' +
908
922
  '<div class="who">' + esc(who) +
909
- (c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
923
+ (c.standing === "protected" ? ' · protected: ' + esc(c.why || "") :
924
+ c.standing === "outside" ? ' · ' + esc(c.why || "outside the map") :
925
+ c.owners && c.owners.length ? ' · owned by ' + esc(c.owners.join(", ")) : ' · owned by nobody') +
910
926
  (cold ? ' · <b>granted since</b>' : '') + '</div>' +
911
927
  '<div class="t"><span class="f" style="width:' + w + '%"></span></div></div>' +
912
928
  '<div class="num">' + c.times.toLocaleString() +
@@ -1056,7 +1072,7 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1056
1072
  host.innerHTML = REQS.map(function(r, i){
1057
1073
  var owners = (r.owners && r.owners.length) ? " — belongs to <b>" + esc(r.owners.join(", ")) + "</b>" : "";
1058
1074
  var times = r.times > 1 ? ' · <span class="asked">asked ' + r.times + "×</span>" : "";
1059
- // Marked, not moved: Approve and Decline post this row's number.
1075
+ // Marked, not moved: Approve and Decline post this row's id.
1060
1076
  var stale = r.stale ? ' · not asked again in ' + Number(r.stale.runs) + ' runs since — likely no longer needed' : "";
1061
1077
  return '<div class="req' + (r.stale ? ' stale' : '') + '">' +
1062
1078
  '<div class="who">' +
@@ -1066,8 +1082,8 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1066
1082
  "</div>" +
1067
1083
  '<div class="act">' +
1068
1084
  '<input id="why-' + i + '" placeholder="why — this outlives you" aria-label="reason">' +
1069
- '<button class="btn" data-n="' + (i+1) + '" data-d="granted" type="button">Approve</button>' +
1070
- '<button class="btn warn" data-n="' + (i+1) + '" data-d="denied" type="button">Decline</button>' +
1085
+ '<button class="btn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="granted" type="button">Approve</button>' +
1086
+ '<button class="btn warn" data-n="' + i + '" data-k="' + escA(r.key) + '" data-d="denied" type="button">Decline</button>' +
1071
1087
  "</div>" +
1072
1088
  "</div>";
1073
1089
  }).join("");
@@ -1122,19 +1138,21 @@ button.btn:active,button.tag:active,button.add:active,button.sw:active{transform
1122
1138
  /**
1123
1139
  * One decision, sent once.
1124
1140
  *
1125
- * The buttons go dead on click because a double-approve is not idempotent
1126
- * here — the second one would be numbered against a queue that already moved.
1141
+ * By the request's id, taken from the row as it was drawn: a position would
1142
+ * be counted against a queue that agents are still writing to. A second click
1143
+ * finds the request already settled and is refused.
1127
1144
  */
1128
1145
  async function act(btn){
1129
- var n = Number(btn.dataset.n);
1130
- var why = (document.getElementById("why-" + (n-1)) || {}).value || "";
1146
+ var i = Number(btn.dataset.n);
1147
+ var key = btn.dataset.k;
1148
+ var why = (document.getElementById("why-" + i) || {}).value || "";
1131
1149
  var row = btn.closest(".req");
1132
1150
  row.querySelectorAll("button,input").forEach(function(e){ e.disabled = true; });
1133
1151
  try {
1134
1152
  var r = await fetch("/api/decide", {
1135
1153
  method: "POST",
1136
1154
  headers: { "content-type": "application/json", "x-seisin-token": TOKEN },
1137
- body: JSON.stringify({ number: n, decision: btn.dataset.d, reason: why })
1155
+ body: JSON.stringify({ key: key, decision: btn.dataset.d, reason: why })
1138
1156
  });
1139
1157
  var out = await r.json();
1140
1158
  if (!r.ok) { var err = new Error(out.error || "denied"); err.status = r.status; throw err; }