seisin 0.3.0 → 0.4.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 CHANGED
@@ -704,7 +704,7 @@ measured](docs/what-it-has-been-put-through.md#where-each-claim-was-actually-run
704
704
 
705
705
  ## Status
706
706
 
707
- `0.3.0`, 452 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
707
+ `0.4.0`, 470 tests, of which **26 need `@anthropic-ai/sandbox-runtime` installed**
708
708
  and run real commands through the real kernel — and CI fails if the sandbox half *skips*, because
709
709
  a green run that quietly tested nothing looks exactly like a real one. That is not hypothetical:
710
710
  those eighteen skipped on Linux for a day, behind a runtime check that looked for the global
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "seisin",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A permission layer for AI coding agents: role-scoped folders and keys, enforced by the OS, and when it blocks it tells you whose file it was.",
5
5
  "keywords": [
6
6
  "ai-agents",
package/src/cli.js CHANGED
@@ -57,7 +57,7 @@ ${C.b}seisin${C.off} — give each agent its own folders and its own keys
57
57
  seisin log verify does the log's hash chain hold
58
58
  seisin watch follow the log live
59
59
  seisin init [--from-observations] propose a ${CONFIG_NAME} for this repo
60
- seisin ui [--port n] open the console, live
60
+ seisin ui [--port n] [--link] open the console, live (--link reopens a running one)
61
61
  seisin mcp an MCP server on stdio — read-only
62
62
 
63
63
  Enforcement comes from @anthropic-ai/sandbox-runtime, which asks the OS.
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { createReadStream, watch as watchDir } from "node:fs";
12
12
  import { dirname, relative } from "node:path";
13
- import { read, logPath, size, verifyChain } from "../log.js";
13
+ import { read, logPath, size, verifyLog } from "../log.js";
14
14
  import { renderEntry, C, out } from "../render.js";
15
15
 
16
16
  const flag = (argv, name) => {
@@ -43,14 +43,15 @@ export function log(config, argv = []) {
43
43
  * before the chain existed are reported as such, not as tampering.
44
44
  */
45
45
  function verify(config) {
46
- const r = verifyChain(logPath(config.root));
46
+ const r = verifyLog(logPath(config.root));
47
47
  if (r.lines === 0) {
48
48
  out(`\n ${C.dim}nothing recorded yet${C.off}\n\n`);
49
49
  return r;
50
50
  }
51
51
  const head = r.unchained ? `${r.unchained} line(s) from before the chain, then ` : "";
52
+ const segs = r.segments > 1 ? ` across ${r.segments} segments` : "";
52
53
  if (!r.breaks.length) {
53
- out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)\n\n`);
54
+ out(`\n ${C.green}intact${C.off} ${head}${r.chained} chained line(s)${segs}\n\n`);
54
55
  return r;
55
56
  }
56
57
  out(`\n ${C.red}broken${C.off} ${head}${r.chained} chained line(s), ${r.breaks.length} break(s):\n`);
@@ -5,22 +5,72 @@
5
5
  * foreground command you start and stop, serving one page and one JSON
6
6
  * document, on loopback. Nothing in the enforcement path touches it. If it is
7
7
  * not running you lose a window, not a boundary.
8
+ *
9
+ * The token that authorizes the page travels only in the link's fragment and is
10
+ * never in an HTTP response (see serve.js). So a lost link cannot be recovered
11
+ * over the wire — only from the terminal that printed it. `writeUiLink` keeps a
12
+ * user-only copy on disk so it can be recovered without reopening that leak:
13
+ * `--link` prints it, and starting over a busy port reopens it instead of just
14
+ * failing. See uilink.js.
8
15
  */
9
16
  import { spawnSync } from "node:child_process";
10
17
  import { relative } from "node:path";
11
18
  import { serve } from "../serve.js";
19
+ import { writeUiLink, readUiLink, clearUiLink } from "../uilink.js";
12
20
  import { C, out } from "../render.js";
13
21
 
22
+ function open(url) {
23
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
24
+ spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
25
+ }
26
+
14
27
  export async function ui(config, argv = []) {
15
28
  const i = argv.indexOf("--port");
16
29
  const port = i === -1 ? 4178 : Number(argv[i + 1]);
17
30
 
18
- const server = await serve(config.path, port);
31
+ // `seisin ui --link`: print the live link a running console left, and open
32
+ // it, without starting anything. For when the terminal that had it is gone.
33
+ if (argv.includes("--link")) {
34
+ const found = readUiLink(port);
35
+ if (found && found.alive !== false) {
36
+ out(`\n ${C.b}${found.url}${C.off}\n ${C.dim}the link a seisin ui on ${port} is serving. open it here, do not paste it anywhere.${C.off}\n\n`);
37
+ open(found.url);
38
+ return;
39
+ }
40
+ if (found && found.alive === false) clearUiLink(port);
41
+ out(`\n ${C.dim}no seisin ui running on ${port} — start one with: seisin ui${port === 4178 ? "" : ` --port ${port}`}${C.off}\n\n`);
42
+ return;
43
+ }
44
+
45
+ let server;
46
+ try {
47
+ server = await serve(config.path, port);
48
+ } catch (e) {
49
+ // A console is already on this port: hand back its live link rather than
50
+ // erroring, which is the case that sends people digging in the terminal.
51
+ if (e.code === "EADDRINUSE") {
52
+ const found = readUiLink(port);
53
+ if (found && found.alive !== false) {
54
+ out(
55
+ `\n ${C.b}${found.url}${C.off}\n` +
56
+ ` ${C.dim}a seisin ui is already running on ${port}; reopening its link. ctrl-c that terminal to stop it.${C.off}\n\n`
57
+ );
58
+ open(found.url);
59
+ return;
60
+ }
61
+ // The port is busy but no live console left a link — a stale record, or
62
+ // something else is on the port. Drop the stale one and let the original
63
+ // "port is busy" message stand.
64
+ if (found && found.alive === false) clearUiLink(port);
65
+ }
66
+ throw e;
67
+ }
68
+
19
69
  const bound = server.address().port;
20
- // The token rides in the fragment, which the browser keeps to itself: no
21
- // request to this server, or to anything else, ever carries it. The page
22
- // moves it into the tab's storage and strips it from the address bar.
23
70
  const url = `http://127.0.0.1:${bound}/#t=${server.seisinToken}`;
71
+ // Recorded before we print, so `--link` and a second `seisin ui` can find it.
72
+ writeUiLink(bound, url);
73
+
24
74
  // local_binding opens every port (macOS); local_ports opens the ones it
25
75
  // names, on every platform — so only a role that names THIS port counts.
26
76
  const reach = Object.values(config.roles)
@@ -31,15 +81,17 @@ export async function ui(config, argv = []) {
31
81
  `\n ${C.b}${url}${C.off}\n` +
32
82
  ` ${C.dim}reading ${relative(process.cwd(), config.path)} and the log, live. ctrl-c to stop.${C.off}\n` +
33
83
  ` ${C.dim}the link carries this run's token: open it here, do not paste it anywhere.${C.off}\n` +
84
+ ` ${C.dim}lost it? this terminal has it above, or run: seisin ui --link${bound === 4178 ? "" : ` --port ${bound}`}${C.off}\n` +
34
85
  (reach.length
35
86
  ? ` ${C.yellow}${reach.join(", ")} can reach this port on localhost: they can load this page, ` +
36
87
  `but not the token, which only travels in the link above.${C.off}\n`
37
88
  : "") + "\n"
38
89
  );
39
90
 
40
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
41
- spawnSync(opener, [url], { stdio: "ignore", shell: process.platform === "win32" });
91
+ open(url);
42
92
 
43
- process.on("SIGINT", () => { server.close(); process.exit(0); });
93
+ const stop = () => { clearUiLink(bound); server.close(); process.exit(0); };
94
+ process.on("SIGINT", stop);
95
+ process.on("SIGTERM", stop);
44
96
  return server;
45
97
  }
package/src/hook.js CHANGED
@@ -38,12 +38,41 @@ const FILE_TOOLS = {
38
38
  const REDIRECT = /(?:^|\s|;|&&|\|\|)>{1,2}\s*("[^"]+"|'[^']+'|[^\s;|&<>]+)/g;
39
39
  const WRITERS = /(?:^|\s|;|&&|\|\|)(?:cp|mv|install|tee|touch|mkdir|rm|rmdir|truncate|dd)\s+([^\n;|&]+)/g;
40
40
 
41
+ /**
42
+ * The server in an `mcp__<server>__<tool>` name, or null if it does not parse.
43
+ *
44
+ * The split is on the double underscore: a server name carries single ones
45
+ * (`claude_ai_Gmail`) and so does a tool name (`execute_sql`), so the server is
46
+ * everything between `mcp__` and the next `__`. Shared with the parent's intake,
47
+ * which recomputes the same verdict rather than trusting the line from the box.
48
+ */
49
+ export function mcpServer(tool) {
50
+ const m = typeof tool === "string" && /^mcp__(.+?)__(.+)$/.exec(tool);
51
+ return m ? m[1] : null;
52
+ }
53
+
41
54
  export function targetsOf(tool, input = {}) {
42
55
  const kind = FILE_TOOLS[tool];
43
56
  if (kind) {
44
57
  const p = input.file_path ?? input.path ?? input.notebook_path;
45
58
  return p ? [{ action: kind, path: p }] : [];
46
59
  }
60
+ /**
61
+ * An MCP tool call names a resource, not a path: `mcp__<server>__<tool>`.
62
+ *
63
+ * The kernel never sees it — the MCP server runs outside the box — so a call
64
+ * that goes unrecorded here goes unrecorded everywhere. Measured 2026-09-24:
65
+ * the event reaches PreToolUse with the tool's own arguments as `tool_input`,
66
+ * which is exactly why the file-tool code above returns nothing for it. We
67
+ * resolve the call to the *server* it belongs to, the unit the policy already
68
+ * declares in `mcp = [...]`, and leave the finer resource (which table, which
69
+ * project) for later — deriving it means a per-server adapter, and that is the
70
+ * line past which this stops being "whose is it" and becomes a tool gateway.
71
+ * A name that does not parse is returned as an unknown tool, not dropped.
72
+ */
73
+ if (typeof tool === "string" && tool.startsWith("mcp__")) {
74
+ return [{ action: "use", tool, server: mcpServer(tool) }];
75
+ }
47
76
  if (tool !== "Bash" || typeof input.command !== "string") return [];
48
77
 
49
78
  const out = [];
@@ -140,6 +169,25 @@ export function decide(config, role, event, { observe = false, now = append, ask
140
169
  const verdicts = [];
141
170
 
142
171
  for (const t of targets) {
172
+ // A tool call, resolved to its MCP server. "Whose is it" is the question
173
+ // `mcp = [...]` already answers, so it is asked through the same map rather
174
+ // than a second one. No request is filed on a denial: a server absent from
175
+ // a role's list is a policy subtraction a person edits — the shape of
176
+ // never_writes — not a grant a person approves, and filing one would be a
177
+ // request nobody can grant. An unparsable name is closed, not waved through.
178
+ if (t.action === "use") {
179
+ const v = t.server
180
+ ? explain(config, role, "mcp", t.server)
181
+ : { allowed: false, owners: [], mcp: true, reason: `${t.tool} is not a recognisable mcp__<server>__<tool> name` };
182
+ now(file, {
183
+ role, tool: event.tool_name, action: "use", kind: "tool", target: t.tool,
184
+ verdict: observe ? "observed" : v.allowed ? "allowed" : "denied",
185
+ owners: v.owners ?? [], reason: v.reason,
186
+ });
187
+ verdicts.push({ ...v, target: t.tool, kind: "tool", action: "use" });
188
+ continue;
189
+ }
190
+
143
191
  const rel = toRepoRelative(config, t.path);
144
192
  const kind = kindOf(config, rel);
145
193
 
@@ -225,6 +273,8 @@ export function decide(config, role, event, { observe = false, now = append, ask
225
273
  ? `${denied.reason}. Do not ask for it: nothing was queued, because the answer is already written down.` + again
226
274
  : denied.gitMetadata
227
275
  ? `${denied.reason}. Nothing was queued.` + again
276
+ : denied.mcp
277
+ ? `${denied.reason}. Nothing was queued: which MCP servers a role may load is set in its mcp list, a policy change a person makes, not a grant they approve.` + again
228
278
  : (denied.owners?.length
229
279
  ? wasRead
230
280
  ? `${denied.target} is declared for ${denied.owners.join(", ")}, not ${role}. Ask for what you need from it rather than reading the key.`
package/src/intake.js CHANGED
@@ -30,6 +30,7 @@ import { explain, ownersOf } from "./owners.js";
30
30
  import { append, logPath } from "./log.js";
31
31
  import { record, requestsPath } from "./requests.js";
32
32
  import { inScope, scopeOf, reachedForContent } from "./violations.js";
33
+ import { mcpServer } from "./hook.js";
33
34
 
34
35
  /** A hash of the policy file as it is now, short: it names a version, it proves nothing. */
35
36
  export function policyId(config) {
@@ -73,6 +74,33 @@ export function intake({ config, role, runId, observe = false, settings, notify
73
74
  */
74
75
  function fromHook(to, entry) {
75
76
  if (!entry || typeof entry !== "object") return;
77
+
78
+ /**
79
+ * An MCP tool call. It names a resource, not a path, so it is recorded and
80
+ * never queued — which servers a role may load is a change to its `mcp`
81
+ * list, not a grant a person approves, the same shape as never_writes. The
82
+ * verdict is recomputed here from the same list rather than believed from
83
+ * inside the box: the server is parsed from the tool name and asked of the
84
+ * policy, so a line claiming "allowed" for a server the role may not load
85
+ * comes back marked `disputed`.
86
+ */
87
+ if (to === "log" && entry.action === "use") {
88
+ if (typeof entry.target !== "string" || !entry.target.trim()) return;
89
+ if (!["allowed", "denied", "observed"].includes(entry.verdict)) return;
90
+ const server = mcpServer(entry.target);
91
+ const v = server ? explain(config, role, "mcp", server) : { allowed: false };
92
+ const expected = observe ? "observed" : v.allowed ? "allowed" : "denied";
93
+ return void log({
94
+ at: entry.at, role,
95
+ tool: String(entry.tool ?? "").slice(0, 40),
96
+ action: "use", kind: "tool",
97
+ target: entry.target.slice(0, 1000),
98
+ verdict: entry.verdict, owners: [],
99
+ reason: String(entry.reason ?? "").slice(0, 500),
100
+ ...(expected !== entry.verdict ? { disputed: expected } : {}),
101
+ });
102
+ }
103
+
76
104
  if (entry.action !== "read" && entry.action !== "write") return;
77
105
  if (typeof entry.target !== "string" || !entry.target.trim()) return;
78
106
 
package/src/log.js CHANGED
@@ -13,9 +13,9 @@
13
13
  * tries the parent's socket first for exactly that reason; writing the file
14
14
  * directly is what happens when there is no parent, outside the box.
15
15
  */
16
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync } from "node:fs";
16
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync } from "node:fs";
17
17
  import { createHash } from "node:crypto";
18
- import { dirname, join } from "node:path";
18
+ import { basename, dirname, join } from "node:path";
19
19
  import { STATE_DIR, LOG_NAME } from "./layout.js";
20
20
  import { send } from "./spool.js";
21
21
 
@@ -39,6 +39,10 @@ export function append(file, entry) {
39
39
  try {
40
40
  mkdirSync(dirname(file), { recursive: true });
41
41
  withLock(file, () => {
42
+ // Rotate first, under the same lock: a full segment is renamed away and a
43
+ // fresh one seeded so the chain continues, then this line lands in the new
44
+ // segment. Done here so the size check and the append never race.
45
+ maybeRotate(file);
42
46
  // `prev` goes last and is computed from the previous line exactly as it
43
47
  // sits on disk, so verifying needs nothing but the file.
44
48
  const line = JSON.stringify({ at: new Date().toISOString(), ...entry, prev: hashOf(lastLine(file)) });
@@ -50,6 +54,68 @@ export function append(file, entry) {
50
54
  }
51
55
  }
52
56
 
57
+ /**
58
+ * When the current segment fills, retire it and start a fresh one that still
59
+ * chains.
60
+ *
61
+ * An append-only file that only grows is the right default and the wrong
62
+ * forever: a busy team writes megabytes of it, and `verify` walks all of them
63
+ * on every run. Rotation caps the size of the file three tools tail while
64
+ * keeping the property that made the chain worth having — that a deletion shows.
65
+ *
66
+ * The retired segment keeps its own chain intact; the new segment's first line
67
+ * is a `rotated` marker whose `prev` is the retired segment's last hash, so the
68
+ * two verify as one chain across the cut (`verifyLog`). The comment on GENESIS
69
+ * asked for exactly this. Segments are numbered upward, `.1` the oldest, and
70
+ * the oldest beyond the keep count are dropped — the only place seisin removes
71
+ * a record, and it removes whole retired segments, never a line.
72
+ */
73
+ const DEFAULT_MAX_BYTES = 8 * 1024 * 1024; // 8 MiB per segment
74
+ const DEFAULT_KEEP = 5; // retired segments kept beside the current one
75
+
76
+ function envInt(name, fallback) {
77
+ const n = Number(process.env[name]);
78
+ return Number.isFinite(n) && n > 0 ? n : fallback;
79
+ }
80
+
81
+ /** Every segment of the log, oldest → newest, current last. */
82
+ export function logSegments(file) {
83
+ const dir = dirname(file);
84
+ const base = basename(file);
85
+ let names = [];
86
+ try { names = readdirSync(dir); } catch { return existsSync(file) ? [file] : []; }
87
+ const archives = names
88
+ .map((n) => {
89
+ if (!n.startsWith(base + ".")) return null;
90
+ const seq = Number(n.slice(base.length + 1));
91
+ return Number.isInteger(seq) && seq > 0 ? { seq, path: join(dir, n) } : null;
92
+ })
93
+ .filter(Boolean)
94
+ .sort((a, b) => a.seq - b.seq)
95
+ .map((a) => a.path);
96
+ if (existsSync(file)) archives.push(file);
97
+ return archives;
98
+ }
99
+
100
+ function maybeRotate(file) {
101
+ let sz = 0;
102
+ try { sz = statSync(file).size; } catch { return; }
103
+ if (sz < envInt("SEISIN_LOG_MAX_BYTES", DEFAULT_MAX_BYTES)) return;
104
+
105
+ const tail = hashOf(lastLine(file));
106
+ const archives = logSegments(file).filter((p) => p !== file);
107
+ const seqs = archives.map((p) => Number(p.slice((file + ".").length))).filter(Number.isInteger);
108
+ const next = (seqs.length ? Math.max(...seqs) : 0) + 1;
109
+ renameSync(file, `${file}.${next}`);
110
+ // Seed the new current so the first real append chains from the retired tail.
111
+ const marker = JSON.stringify({ at: new Date().toISOString(), event: "rotated", from: `${basename(file)}.${next}`, prev: tail });
112
+ appendFileSync(file, marker + "\n");
113
+
114
+ const keep = envInt("SEISIN_LOG_KEEP", DEFAULT_KEEP);
115
+ const retired = logSegments(file).filter((p) => p !== file); // oldest → newest, includes the new archive
116
+ for (let i = 0; i < retired.length - keep; i++) { try { unlinkSync(retired[i]); } catch {} }
117
+ }
118
+
53
119
  /**
54
120
  * The log is chained: every line carries `prev`, the hash of the line before it.
55
121
  *
@@ -116,8 +182,8 @@ function withLock(file, fn) {
116
182
  * unchained prefix, not reported as tampering. After the first chained line,
117
183
  * a line without `prev` is itself a break.
118
184
  */
119
- export function verifyChain(file) {
120
- const out = { lines: 0, unchained: 0, chained: 0, breaks: [] };
185
+ function walk(file, { seed = GENESIS, startLine = 0 } = {}) {
186
+ const out = { lines: 0, unchained: 0, chained: 0, breaks: [], tail: null };
121
187
  if (!existsSync(file)) return out;
122
188
  let previous = null;
123
189
  let started = false;
@@ -129,15 +195,54 @@ export function verifyChain(file) {
129
195
  const prev = e?.prev;
130
196
  if (!started && prev === undefined) { out.unchained++; previous = line; continue; }
131
197
  started = true;
132
- const expected = out.chained === 0 && out.unchained === 0 ? GENESIS : hashOf(previous);
133
- const ok = prev === expected || (out.chained === 0 && prev === hashOf(previous));
134
- if (!ok) out.breaks.push({ line: out.lines, expected, found: prev ?? null });
198
+ const first = out.chained === 0 && out.unchained === 0;
199
+ // A rotation marker leading a segment we have nothing to check it against —
200
+ // a file verified on its own, or the oldest segment left after the prefix
201
+ // was pruned for retention (seed still GENESIS) — is accepted rather than
202
+ // called a break. For any later segment the seed is the real previous tail,
203
+ // so the marker's `prev` IS checked, and a whole segment deleted or
204
+ // reordered in the middle shows.
205
+ const rotationStart = first && e?.event === "rotated" && seed === GENESIS;
206
+ const expected = first ? seed : hashOf(previous);
207
+ const ok = rotationStart || prev === expected || (first && prev === hashOf(previous));
208
+ if (!ok) out.breaks.push({ line: startLine + out.lines, expected, found: prev ?? null });
135
209
  out.chained++;
136
210
  previous = line;
137
211
  }
212
+ out.tail = previous;
138
213
  return out;
139
214
  }
140
215
 
216
+ export function verifyChain(file, seed = GENESIS) {
217
+ const r = walk(file, { seed });
218
+ return { lines: r.lines, unchained: r.unchained, chained: r.chained, breaks: r.breaks };
219
+ }
220
+
221
+ /**
222
+ * Verify the whole log, across every rotated segment, as one chain.
223
+ *
224
+ * Each retired segment is walked in order, and its last hash becomes the seed
225
+ * the next segment's `rotated` marker must match — so deleting or reordering a
226
+ * whole segment shows, not only an edit inside one. With no rotation there is a
227
+ * single segment and this is exactly `verifyChain`. What it cannot see is a
228
+ * segment dropped off the *oldest* end by the keep limit: that is retention, not
229
+ * tampering, and the count of segments is reported so the drop is not silent.
230
+ */
231
+ export function verifyLog(file) {
232
+ const segs = logSegments(file);
233
+ const agg = { lines: 0, unchained: 0, chained: 0, breaks: [], segments: segs.length };
234
+ let seed = GENESIS;
235
+ for (const seg of segs) {
236
+ const r = walk(seg, { seed, startLine: agg.lines });
237
+ agg.lines += r.lines;
238
+ agg.unchained += r.unchained;
239
+ agg.chained += r.chained;
240
+ agg.breaks.push(...r.breaks);
241
+ seed = hashOf(r.tail);
242
+ }
243
+ return agg;
244
+ }
245
+
141
246
  /** Reads entries, newest last. A malformed line is skipped, never fatal. */
142
247
  export function read(file, { role, verdict, since, limit = 0 } = {}) {
143
248
  if (!existsSync(file)) return [];
@@ -150,6 +255,7 @@ export function read(file, { role, verdict, since, limit = 0 } = {}) {
150
255
  } catch {
151
256
  continue; // a half-written line from a killed process is not an error
152
257
  }
258
+ if (e.event === "rotated") continue; // a structural marker, not a decision
153
259
  if (role && e.role !== role) continue;
154
260
  if (verdict && e.verdict !== verdict) continue;
155
261
  if (since && e.at < since) continue;
package/src/serve.js CHANGED
@@ -406,7 +406,9 @@ export function serve(configPath, port = 4178) {
406
406
  });
407
407
  server.on("error", (e) =>
408
408
  fail(e.code === "EADDRINUSE"
409
- ? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
409
+ // Keep the code on the rejection: `seisin ui` reads it to tell "a
410
+ // console is already here, reopen its link" from any other failure.
411
+ ? Object.assign(new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`), { code: "EADDRINUSE" })
410
412
  : e));
411
413
  });
412
414
  }
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
+ }