seisin 0.1.1 → 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 +287 -339
- package/package.json +7 -6
- package/src/cli.js +17 -3
- package/src/commands/explain.js +9 -2
- package/src/commands/hook.js +11 -0
- package/src/commands/init.js +1 -1
- package/src/commands/log.js +27 -1
- package/src/commands/requests.js +8 -3
- package/src/commands/review.js +33 -5
- package/src/commands/run.js +180 -151
- package/src/commands/ui.js +16 -2
- package/src/commands/walls.js +30 -0
- package/src/commands/whose.js +2 -1
- package/src/commands/wire.js +30 -5
- package/src/config.js +310 -5
- package/src/diagnose.js +172 -0
- package/src/env.js +54 -1
- package/src/grants.js +239 -0
- package/src/hook.js +45 -5
- package/src/index.js +12 -0
- package/src/inspect.js +332 -9
- package/src/intake.js +215 -0
- package/src/keys.js +585 -0
- package/src/log.js +96 -2
- package/src/mcp.js +87 -11
- package/src/nested.js +86 -0
- package/src/notify.js +146 -0
- package/src/owners.js +208 -6
- package/src/paths.js +50 -0
- package/src/render.js +63 -1
- package/src/requests.js +88 -0
- package/src/review.js +9 -4
- package/src/rundir.js +113 -0
- package/src/serve.js +263 -19
- package/src/spool.js +98 -20
- package/src/srt.js +148 -203
- package/src/surface.js +518 -0
- package/src/violations.js +37 -5
- package/src/walls.js +184 -0
- package/ui/favicon.svg +1 -1
- package/ui/index.html +585 -56
package/src/serve.js
CHANGED
|
@@ -18,7 +18,9 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
import { loadConfig } from "./config.js";
|
|
19
19
|
import { read, logPath } from "./log.js";
|
|
20
20
|
import { settingsFor } from "./srt.js";
|
|
21
|
-
import { pending, requestsPath, settle, applyGrant } from "./requests.js";
|
|
21
|
+
import { pending, requestsPath, settle, applyGrant, refuseIfBarred, markStale } from "./requests.js";
|
|
22
|
+
import { walls } from "./walls.js";
|
|
23
|
+
import { explain } from "./owners.js";
|
|
22
24
|
|
|
23
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
26
|
|
|
@@ -29,23 +31,165 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
29
31
|
* page is a view of it — someone editing seisin.toml in an editor should see
|
|
30
32
|
* the console change, not wonder why it disagrees.
|
|
31
33
|
*/
|
|
32
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Today's denials, grouped by what was denied rather than by who asked.
|
|
36
|
+
*
|
|
37
|
+
* Called `causes` and not `friction`: `seisin review` already has a `friction`
|
|
38
|
+
* that counts something narrower — repeated denials that are not about keys —
|
|
39
|
+
* and two screens of one product reporting different numbers under one label
|
|
40
|
+
* is the kind of thing nobody notices until they are compared.
|
|
41
|
+
*
|
|
42
|
+
* The console used to show them per role, which is the same list a hundred
|
|
43
|
+
* times over: measured on one deployment, 1,080 of 1,422 refusals were a
|
|
44
|
+
* single lock file across six repositories, and per-role that reads as six
|
|
45
|
+
* hundred identical rows instead of one sentence. Grouped, the shape of the
|
|
46
|
+
* day is visible at a glance and it is almost never a territory dispute.
|
|
47
|
+
*
|
|
48
|
+
* `stillRefused` is recomputed against the policy as it stands, not read out
|
|
49
|
+
* of the log — a cause that has been granted since is history, not friction,
|
|
50
|
+
* and leaving it on the page sends somebody to fix what is already fixed.
|
|
51
|
+
*/
|
|
52
|
+
export function causesOf(cfg, entries) {
|
|
53
|
+
const by = new Map();
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (e.verdict !== "denied" || !e.target || !e.action) continue;
|
|
56
|
+
const k = `${e.action}\u0000${e.target}`;
|
|
57
|
+
const g = by.get(k) ?? { action: e.action, target: e.target, times: 0, roles: new Set(), owners: e.owners ?? [] };
|
|
58
|
+
g.times++;
|
|
59
|
+
g.roles.add(e.role);
|
|
60
|
+
by.set(k, g);
|
|
61
|
+
}
|
|
62
|
+
const total = [...by.values()].reduce((n, g) => n + g.times, 0);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The same grouping again, one level coarser: by the NAME at the end of the
|
|
66
|
+
* path rather than the path.
|
|
67
|
+
*
|
|
68
|
+
* Grouping by path alone gets the most important reading backwards. Measured
|
|
69
|
+
* here: six of the top seven causes were `.git/index.lock` in six different
|
|
70
|
+
* repositories, no single one above 17% — so "no cause dominates" is what the
|
|
71
|
+
* arithmetic says and the opposite of what is true. Three quarters of the
|
|
72
|
+
* window was one *kind* of thing, and that is a tooling problem with a
|
|
73
|
+
* mechanical fix, not a territory question anybody needs to rule on.
|
|
74
|
+
*
|
|
75
|
+
* By last segment, and nothing cleverer. A regex that recognised lock files,
|
|
76
|
+
* caches and build outputs would be a list of guesses about other people's
|
|
77
|
+
* toolchains that quietly goes stale; a repeated filename is a fact about
|
|
78
|
+
* this log.
|
|
79
|
+
*/
|
|
80
|
+
const fams = new Map();
|
|
81
|
+
for (const g of by.values()) {
|
|
82
|
+
const name = g.target.split("/").filter(Boolean).pop() ?? g.target;
|
|
83
|
+
const f = fams.get(name) ?? { name, times: 0, paths: 0, where: [], roles: new Set() };
|
|
84
|
+
f.times += g.times;
|
|
85
|
+
f.paths++;
|
|
86
|
+
// The places, so the console can show what a name is made of instead of
|
|
87
|
+
// asserting a percentage the reader has to take on faith.
|
|
88
|
+
f.where.push({ target: g.target, times: g.times });
|
|
89
|
+
for (const r of g.roles) f.roles.add(r);
|
|
90
|
+
fams.set(name, f);
|
|
91
|
+
}
|
|
92
|
+
const families = [...fams.values()]
|
|
93
|
+
.sort((a, b) => b.times - a.times)
|
|
94
|
+
.map((f) => ({
|
|
95
|
+
name: f.name, times: f.times, paths: f.paths,
|
|
96
|
+
share: total ? f.times / total : 0,
|
|
97
|
+
roles: [...f.roles].sort(),
|
|
98
|
+
where: f.where.sort((a, b) => b.times - a.times).slice(0, 20),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What a `grep` over the log cannot tell you.
|
|
103
|
+
*
|
|
104
|
+
* The headline used to be "74% of it is one name", and a field review
|
|
105
|
+
* applied this project's own test to it: *did the number tell you something
|
|
106
|
+
* you did not know?* For somebody who reads the raw log, no — they had
|
|
107
|
+
* already counted that. The console was repeating the log back.
|
|
108
|
+
*
|
|
109
|
+
* These two are different, because they need the **policy** and the log does
|
|
110
|
+
* not contain it. `unowned` is the count of causes on paths no role claims:
|
|
111
|
+
* no grant resolves those until somebody decides who owns them, which is a
|
|
112
|
+
* decision rather than a number. `settled` is friction that has since been
|
|
113
|
+
* granted — real yesterday, noise today.
|
|
114
|
+
*/
|
|
115
|
+
const live = [...by.values()];
|
|
116
|
+
const unowned = live.filter((g) => !(g.owners ?? []).length).length;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
total,
|
|
120
|
+
unowned,
|
|
121
|
+
// The real number of distinct causes, not the length of the list below.
|
|
122
|
+
// The page says "over N distinct paths" and the list is capped at twelve,
|
|
123
|
+
// so taking N from the list reported the cap as if it were the count —
|
|
124
|
+
// a wrong number stated confidently, which is worse than no number.
|
|
125
|
+
distinct: by.size,
|
|
126
|
+
families: families.slice(0, 8),
|
|
127
|
+
causes: [...by.values()]
|
|
128
|
+
.sort((a, b) => b.times - a.times)
|
|
129
|
+
.slice(0, 12)
|
|
130
|
+
.map((g) => ({
|
|
131
|
+
action: g.action,
|
|
132
|
+
target: g.target,
|
|
133
|
+
times: g.times,
|
|
134
|
+
share: total ? g.times / total : 0,
|
|
135
|
+
roles: [...g.roles].sort(),
|
|
136
|
+
owners: g.owners,
|
|
137
|
+
// How many of the roles that hit this would still hit it. `some` and
|
|
138
|
+
// not `every`: two roles out of three still blocked is still friction,
|
|
139
|
+
// and requiring all of them would quietly retire a live cause the day
|
|
140
|
+
// one role got a grant.
|
|
141
|
+
stillRefused: [...g.roles].filter((r) => cfg.roles[r] && !explain(cfg, r, g.action, g.target).allowed).length,
|
|
142
|
+
})),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `?since=<ISO date>`, or null for no filter. Anything that does not parse is
|
|
148
|
+
* ignored rather than refused: a bad filter should show everything, not an error.
|
|
149
|
+
*/
|
|
150
|
+
export function sinceOf(url) {
|
|
151
|
+
const raw = new URLSearchParams((url ?? "").split("?")[1] ?? "").get("since");
|
|
152
|
+
const t = raw ? Date.parse(raw) : NaN;
|
|
153
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function state(configPath, { since = null } = {}) {
|
|
33
157
|
const cfg = loadConfig(configPath);
|
|
34
|
-
|
|
158
|
+
// The date filter narrows everything read from the log: activity, causes,
|
|
159
|
+
// walls and the per-role counts. Not the request queue, which is what is
|
|
160
|
+
// waiting now whatever the window.
|
|
161
|
+
const entries = read(logPath(cfg.root), { limit: 200, since });
|
|
162
|
+
// The whole window, not a tail. "All" used to mean the last 4000 lines, so a
|
|
163
|
+
// 30-day window could show more denials than "all" did (6k against 4k on a
|
|
164
|
+
// real log). Every count on the page comes from this list.
|
|
165
|
+
const forShape = read(logPath(cfg.root), since ? { since } : {});
|
|
35
166
|
|
|
36
167
|
const roles = Object.values(cfg.roles).map((r) => ({
|
|
37
168
|
name: r.name,
|
|
38
169
|
writes: r.writes,
|
|
39
170
|
keys: r.keys,
|
|
40
171
|
env: r.env,
|
|
41
|
-
|
|
172
|
+
neverWrites: r.neverWritesDeclared ?? r.neverWrites ?? [],
|
|
173
|
+
localBinding: r.localBinding === true,
|
|
174
|
+
localPorts: r.localPorts ?? [],
|
|
175
|
+
mcp: r.mcp ?? null,
|
|
176
|
+
trustd: r.trustd === true,
|
|
177
|
+
// Counted over the same window as the causes, so the KPI and the screen it
|
|
178
|
+
// links to agree.
|
|
179
|
+
blocks: forShape.filter((e) => e.role === r.name && e.verdict === "denied").length,
|
|
42
180
|
settings: settingsFor(cfg, r.name),
|
|
43
181
|
}));
|
|
44
182
|
|
|
183
|
+
// Read once for every role's walls, rather than once per role.
|
|
184
|
+
const whole = read(logPath(cfg.root), { verdict: "denied", since });
|
|
185
|
+
// Staleness is about the role's whole history, never the window: a request
|
|
186
|
+
// is not old just because the filter hides the runs that made it old.
|
|
187
|
+
const history = since ? read(logPath(cfg.root), { verdict: "denied" }) : whole;
|
|
45
188
|
return {
|
|
46
189
|
root: cfg.root,
|
|
47
190
|
config: cfg.path,
|
|
48
|
-
requests: pending(requestsPath(cfg.root)),
|
|
191
|
+
requests: markStale(pending(requestsPath(cfg.root)), history),
|
|
192
|
+
since,
|
|
49
193
|
// The file itself, not a regeneration of it. The page can render a policy
|
|
50
194
|
// from its own model, and that model has no comments — so showing it under
|
|
51
195
|
// the heading "seisin.toml" next to "copy this back" invites someone to
|
|
@@ -54,6 +198,13 @@ function state(configPath) {
|
|
|
54
198
|
keyDirs: cfg.keyDirs,
|
|
55
199
|
allowedDomains: cfg.allowedDomains,
|
|
56
200
|
roles,
|
|
201
|
+
causes: causesOf(cfg, forShape),
|
|
202
|
+
// What each role keeps being refused AND would still be refused today.
|
|
203
|
+
// Empty for a role that has hit nothing twice, which is most of them.
|
|
204
|
+
walls: Object.fromEntries(
|
|
205
|
+
Object.keys(cfg.roles)
|
|
206
|
+
.map((r) => [r, walls(cfg, r, { entries: whole })])
|
|
207
|
+
.filter(([, w]) => w.length)),
|
|
57
208
|
log: entries.slice(-60).reverse(),
|
|
58
209
|
live: true,
|
|
59
210
|
};
|
|
@@ -81,6 +232,7 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
81
232
|
// The config is edited as text, so comments and order survive. Written
|
|
82
233
|
// before the queue is settled: if this throws, the request is still open
|
|
83
234
|
// rather than marked done against a file that never changed.
|
|
235
|
+
refuseIfBarred(cfg, req);
|
|
84
236
|
const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
|
|
85
237
|
if (changed) writeFileSync(cfg.path, toml);
|
|
86
238
|
}
|
|
@@ -89,17 +241,49 @@ function decide(configPath, { number, decision, reason }) {
|
|
|
89
241
|
}
|
|
90
242
|
|
|
91
243
|
/** The body of a POST, capped: this endpoint takes three short fields. */
|
|
92
|
-
|
|
244
|
+
/**
|
|
245
|
+
* A request body, capped. The cap is per route: one decision is a few hundred
|
|
246
|
+
* bytes, but "decline all" carries every key on screen — 109 of them were
|
|
247
|
+
* 9.3 KB, over the old flat 4 KB, and the connection was cut with nothing but
|
|
248
|
+
* "Failed to fetch" in the browser. Found by Carlos on the first real use.
|
|
249
|
+
*/
|
|
250
|
+
function readBody(req, max = 4096) {
|
|
93
251
|
return new Promise((ok, fail) => {
|
|
94
252
|
let s = "";
|
|
95
253
|
req.on("data", (c) => {
|
|
96
254
|
s += c;
|
|
97
|
-
if (s.length >
|
|
255
|
+
if (s.length > max) { fail(new Error("body too large")); req.destroy(); }
|
|
98
256
|
});
|
|
99
257
|
req.on("end", () => ok(s));
|
|
100
258
|
});
|
|
101
259
|
}
|
|
102
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Refuse every request the page was showing, in one go.
|
|
263
|
+
*
|
|
264
|
+
* Only refusing. There is no "approve all" and there will not be: a grant is a
|
|
265
|
+
* decision about one path and one owner, and doing forty of them without
|
|
266
|
+
* reading them is the permissive default this tool exists to stand against.
|
|
267
|
+
* Refusing in bulk is safe — it grants nothing and changes no policy — and it
|
|
268
|
+
* is what a queue full of noise from a bug already fixed needs.
|
|
269
|
+
*
|
|
270
|
+
* By key, not "whatever is pending now": a request that arrived after the page
|
|
271
|
+
* drew is one the person has not seen, and it stays.
|
|
272
|
+
*/
|
|
273
|
+
function declineAll(configPath, { keys, reason }) {
|
|
274
|
+
if (!Array.isArray(keys) || !keys.length) throw new Error("keys must list the requests on screen");
|
|
275
|
+
const cfg = loadConfig(configPath);
|
|
276
|
+
const file = requestsPath(cfg.root);
|
|
277
|
+
const wanted = new Set(keys.map(String));
|
|
278
|
+
let declined = 0;
|
|
279
|
+
for (const req of pending(file)) {
|
|
280
|
+
if (!wanted.has(req.key)) continue;
|
|
281
|
+
settle(file, req.key, "denied", reason ?? "");
|
|
282
|
+
declined++;
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, declined };
|
|
285
|
+
}
|
|
286
|
+
|
|
103
287
|
export function serve(configPath, port = 4178) {
|
|
104
288
|
const page = join(HERE, "..", "ui", "index.html");
|
|
105
289
|
if (!existsSync(page)) throw new Error("the console is missing from this install");
|
|
@@ -114,12 +298,69 @@ export function serve(configPath, port = 4178) {
|
|
|
114
298
|
*/
|
|
115
299
|
const token = randomBytes(24).toString("hex");
|
|
116
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Every route under /api/ asks for the token, reads included.
|
|
303
|
+
*
|
|
304
|
+
* The page used to be the only thing that carried the token and /api/state
|
|
305
|
+
* answered anyone. Both rested on "an agent cannot reach loopback", and
|
|
306
|
+
* `local_binding = true` on macOS makes that false: the runtime lets that
|
|
307
|
+
* role connect to every localhost port, and loopback bypasses the egress
|
|
308
|
+
* proxy. Reproduced end to end by a read-only review on 2026-09-22 — a role
|
|
309
|
+
* read the token off `GET /`, posted to /api/decide, and approved its own
|
|
310
|
+
* request into another role's territory. /api/state handed it the policy, the
|
|
311
|
+
* log and where every key lives, with no token at all.
|
|
312
|
+
*
|
|
313
|
+
* So the token no longer travels in any response. `seisin ui` puts it in the
|
|
314
|
+
* URL fragment, which a browser never sends to a server; the page takes it
|
|
315
|
+
* from there, keeps it for the tab, and removes it from the address bar and
|
|
316
|
+
* the history entry. Anything that fetches `/` gets a page with no token in it.
|
|
317
|
+
*/
|
|
318
|
+
const authorized = (req) => req.headers["x-seisin-token"] === token;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The Host header has to name this server.
|
|
322
|
+
*
|
|
323
|
+
* A page on the operator's browser can point a hostname it controls at
|
|
324
|
+
* 127.0.0.1 (DNS rebinding) and then read responses as same-origin. It still
|
|
325
|
+
* could not produce the token, but the check is one line and makes the
|
|
326
|
+
* question moot: the only names this server answers to are its own.
|
|
327
|
+
*/
|
|
328
|
+
const hostOk = (req, port) => {
|
|
329
|
+
const h = String(req.headers.host ?? "");
|
|
330
|
+
return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The path, without whatever came after `?`.
|
|
335
|
+
*
|
|
336
|
+
* `req.url` is the raw request target, so a query string made every route
|
|
337
|
+
* miss: `http://localhost:4178/?anything` answered 404 on the one page this
|
|
338
|
+
* server has. Harmless until a launcher appends a parameter or a link is
|
|
339
|
+
* shared with a suffix on it, and then the console just looks broken.
|
|
340
|
+
*/
|
|
341
|
+
const pathOf = (u) => (u ?? "/").split("?")[0];
|
|
342
|
+
|
|
117
343
|
const server = createServer(async (req, res) => {
|
|
118
|
-
if (req
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
344
|
+
if (!hostOk(req, server.address()?.port)) {
|
|
345
|
+
res.writeHead(403, { "content-type": "text/plain" });
|
|
346
|
+
return res.end("wrong host");
|
|
347
|
+
}
|
|
348
|
+
if (pathOf(req.url).startsWith("/api/") && !authorized(req)) {
|
|
349
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
350
|
+
return res.end(JSON.stringify({ error: "bad or missing token — open the URL `seisin ui` printed" }));
|
|
351
|
+
}
|
|
352
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decline-all") {
|
|
353
|
+
try {
|
|
354
|
+
// ~85 bytes per key: 1 MB is ten thousand requests on one screen.
|
|
355
|
+
const out = declineAll(configPath, JSON.parse((await readBody(req, 1024 * 1024)) || "{}"));
|
|
356
|
+
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
357
|
+
return res.end(JSON.stringify(out));
|
|
358
|
+
} catch (e) {
|
|
359
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
360
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
122
361
|
}
|
|
362
|
+
}
|
|
363
|
+
if (req.method === "POST" && pathOf(req.url) === "/api/decide") {
|
|
123
364
|
try {
|
|
124
365
|
const out = decide(configPath, JSON.parse((await readBody(req)) || "{}"));
|
|
125
366
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
@@ -133,10 +374,10 @@ export function serve(configPath, port = 4178) {
|
|
|
133
374
|
res.writeHead(405).end("method not allowed");
|
|
134
375
|
return;
|
|
135
376
|
}
|
|
136
|
-
if (req.url === "/api/state") {
|
|
377
|
+
if (pathOf(req.url) === "/api/state") {
|
|
137
378
|
let body;
|
|
138
379
|
try {
|
|
139
|
-
body = JSON.stringify(state(configPath));
|
|
380
|
+
body = JSON.stringify(state(configPath, { since: sinceOf(req.url) }));
|
|
140
381
|
} catch (e) {
|
|
141
382
|
res.writeHead(500, { "content-type": "application/json" });
|
|
142
383
|
return res.end(JSON.stringify({ error: e.message }));
|
|
@@ -145,11 +386,10 @@ export function serve(configPath, port = 4178) {
|
|
|
145
386
|
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
|
|
146
387
|
return res.end(body);
|
|
147
388
|
}
|
|
148
|
-
if (req.url === "/" || req.url === "/index.html") {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
const html = readFileSync(page, "utf8")
|
|
152
|
-
.replace("</head>", `<script>window.SEISIN_TOKEN=${JSON.stringify(token)}</script></head>`);
|
|
389
|
+
if (pathOf(req.url) === "/" || pathOf(req.url) === "/index.html") {
|
|
390
|
+
// No token in here. It used to be inlined, which handed it to anything
|
|
391
|
+
// that could GET this page — see `authorized` above.
|
|
392
|
+
const html = readFileSync(page, "utf8");
|
|
153
393
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
154
394
|
return res.end(html);
|
|
155
395
|
}
|
|
@@ -159,7 +399,11 @@ export function serve(configPath, port = 4178) {
|
|
|
159
399
|
return new Promise((ok, fail) => {
|
|
160
400
|
// Loopback only. The page maps where every credential lives; it has no
|
|
161
401
|
// business being reachable from the network the laptop happens to be on.
|
|
162
|
-
server.listen(port, "127.0.0.1", () =>
|
|
402
|
+
server.listen(port, "127.0.0.1", () => {
|
|
403
|
+
// For `seisin ui`, which builds the URL, and for tests. Never sent.
|
|
404
|
+
Object.defineProperty(server, "seisinToken", { value: token });
|
|
405
|
+
ok(server);
|
|
406
|
+
});
|
|
163
407
|
server.on("error", (e) =>
|
|
164
408
|
fail(e.code === "EADDRINUSE"
|
|
165
409
|
? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
|
package/src/spool.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* solved here; the log is evidence of what happened, not proof that nothing
|
|
27
27
|
* else did.
|
|
28
28
|
*/
|
|
29
|
-
import { createServer, createConnection } from "node:net";
|
|
30
|
-
import { unlinkSync, existsSync, mkdtempSync, chmodSync } from "node:fs";
|
|
29
|
+
import { createServer, createConnection, Socket } from "node:net";
|
|
30
|
+
import { unlinkSync, existsSync, mkdtempSync, chmodSync, openSync, writeSync, closeSync, constants } from "node:fs";
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
31
32
|
import { tmpdir } from "node:os";
|
|
32
33
|
import { join, dirname } from "node:path";
|
|
33
34
|
|
|
@@ -59,29 +60,78 @@ export function spoolPath() {
|
|
|
59
60
|
* in. The parent decides what that means; the sender does not get to pick a
|
|
60
61
|
* path, which is the other half of why this is not just a slower file write.
|
|
61
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Which channel this platform can use: a unix socket, or a FIFO.
|
|
65
|
+
*
|
|
66
|
+
* On Linux the runtime blocks creating ANY unix socket inside the box with a
|
|
67
|
+
* seccomp filter — it cannot filter by path, so `allowUnixSockets` is ignored
|
|
68
|
+
* there (its README says so). The socket this file was built on therefore
|
|
69
|
+
* never carried a line on Linux: measured in Docker on 2026-09-23, with the
|
|
70
|
+
* code as published in 0.2.0 and after, the hook "sent" and the log stayed
|
|
71
|
+
* empty. A FIFO is a file, not a socket — seccomp does not see it, bubblewrap
|
|
72
|
+
* grants it by path, and a write of up to PIPE_BUF bytes is atomic, so lines
|
|
73
|
+
* from concurrent tool calls do not interleave. Measured the same day: the
|
|
74
|
+
* role wrote a line through it, could not create anything beside it, and
|
|
75
|
+
* could not remove it.
|
|
76
|
+
*/
|
|
77
|
+
export const FIFO_SUFFIX = ".fifo";
|
|
78
|
+
export const channelName = (platform = process.platform) => (platform === "linux" ? "s.fifo" : "s.sock");
|
|
79
|
+
|
|
80
|
+
/** The most a FIFO write carries atomically on Linux (PIPE_BUF). */
|
|
81
|
+
const PIPE_BUF = 4096;
|
|
82
|
+
|
|
83
|
+
/** Parses newline-separated JSON messages and hands each to `sink`. */
|
|
84
|
+
function lineReader(sink, onTooLong) {
|
|
85
|
+
let buffer = "";
|
|
86
|
+
return (chunk) => {
|
|
87
|
+
buffer += chunk;
|
|
88
|
+
// A 1 MB line is not a log entry, it is something trying to fill a disk.
|
|
89
|
+
if (buffer.length > 1_000_000) { buffer = ""; return onTooLong?.(); }
|
|
90
|
+
let nl;
|
|
91
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
92
|
+
const line = buffer.slice(0, nl);
|
|
93
|
+
buffer = buffer.slice(nl + 1);
|
|
94
|
+
if (!line.trim()) continue;
|
|
95
|
+
try {
|
|
96
|
+
const msg = JSON.parse(line);
|
|
97
|
+
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
98
|
+
} catch {
|
|
99
|
+
// Garbage from inside the box is not the parent's emergency.
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The FIFO end of the spool. Opened read-write so it never sees end-of-file
|
|
107
|
+
* when a writer closes — every hook call opens, writes one line and closes —
|
|
108
|
+
* and read through a net.Socket, which takes a pipe descriptor and reads it
|
|
109
|
+
* without holding a thread.
|
|
110
|
+
*/
|
|
111
|
+
function fifoSpool(sink, path) {
|
|
112
|
+
if (existsSync(path)) unlinkSync(path);
|
|
113
|
+
execFileSync("mkfifo", ["-m", "600", path]);
|
|
114
|
+
const fd = openSync(path, constants.O_RDWR | constants.O_NONBLOCK);
|
|
115
|
+
const stream = new Socket({ fd, readable: true, writable: false });
|
|
116
|
+
stream.setEncoding("utf8");
|
|
117
|
+
stream.on("data", lineReader(sink));
|
|
118
|
+
stream.on("error", () => {});
|
|
119
|
+
return Promise.resolve({
|
|
120
|
+
path,
|
|
121
|
+
close() {
|
|
122
|
+
stream.destroy();
|
|
123
|
+
try { unlinkSync(path); } catch {}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
62
128
|
export function spool(sink, path = spoolPath()) {
|
|
129
|
+
if (path.endsWith(FIFO_SUFFIX)) return fifoSpool(sink, path);
|
|
63
130
|
if (existsSync(path)) unlinkSync(path);
|
|
64
131
|
|
|
65
132
|
const server = createServer((conn) => {
|
|
66
|
-
let buffer = "";
|
|
67
133
|
conn.setEncoding("utf8");
|
|
68
|
-
conn.on("data", (
|
|
69
|
-
buffer += chunk;
|
|
70
|
-
// A 1 MB line is not a log entry, it is something trying to fill a disk.
|
|
71
|
-
if (buffer.length > 1_000_000) return conn.destroy();
|
|
72
|
-
let nl;
|
|
73
|
-
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
74
|
-
const line = buffer.slice(0, nl);
|
|
75
|
-
buffer = buffer.slice(nl + 1);
|
|
76
|
-
if (!line.trim()) continue;
|
|
77
|
-
try {
|
|
78
|
-
const msg = JSON.parse(line);
|
|
79
|
-
if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
|
|
80
|
-
} catch {
|
|
81
|
-
// Garbage from inside the box is not the parent's emergency.
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
});
|
|
134
|
+
conn.on("data", lineReader(sink, () => conn.destroy()));
|
|
85
135
|
conn.on("error", () => {});
|
|
86
136
|
});
|
|
87
137
|
|
|
@@ -136,6 +186,7 @@ const inFlight = new Set();
|
|
|
136
186
|
|
|
137
187
|
export function send(to, entry, path = process.env[SOCK_ENV]) {
|
|
138
188
|
if (!path) return false;
|
|
189
|
+
if (path.endsWith(FIFO_SUFFIX)) return sendFifo(to, entry, path);
|
|
139
190
|
const p = new Promise((done) => {
|
|
140
191
|
let settled = false;
|
|
141
192
|
const finish = () => { if (!settled) { settled = true; done(true); } };
|
|
@@ -162,3 +213,30 @@ export function send(to, entry, path = process.env[SOCK_ENV]) {
|
|
|
162
213
|
export async function flush() {
|
|
163
214
|
await Promise.all([...inFlight]);
|
|
164
215
|
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One line into the FIFO, synchronously: nothing to flush, nothing to lose to
|
|
219
|
+
* `process.exit`. Non-blocking, so a parent that is gone (no reader) costs an
|
|
220
|
+
* ENXIO and not a hung tool call. Longer than PIPE_BUF, the free-text fields
|
|
221
|
+
* are shortened first — past that size a write is no longer atomic and two
|
|
222
|
+
* tool calls could interleave their lines.
|
|
223
|
+
*/
|
|
224
|
+
function sendFifo(to, entry, path) {
|
|
225
|
+
const full = { at: new Date().toISOString(), ...entry };
|
|
226
|
+
let line = JSON.stringify({ to, entry: full }) + "\n";
|
|
227
|
+
if (Buffer.byteLength(line) > PIPE_BUF) {
|
|
228
|
+
const short = { ...full, reason: String(full.reason ?? "").slice(0, 300), target: String(full.target ?? "").slice(0, 1000) };
|
|
229
|
+
line = JSON.stringify({ to, entry: short }) + "\n";
|
|
230
|
+
if (Buffer.byteLength(line) > PIPE_BUF) return false;
|
|
231
|
+
}
|
|
232
|
+
let fd;
|
|
233
|
+
try {
|
|
234
|
+
fd = openSync(path, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
235
|
+
writeSync(fd, line);
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
} finally {
|
|
240
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|