seisin 0.6.3 → 0.6.5
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 +143 -686
- package/package.json +2 -2
- package/src/canary.js +1 -1
- package/src/cli.js +47 -3
- package/src/commands/check.js +68 -12
- package/src/commands/hook.js +1 -1
- package/src/commands/log.js +21 -14
- package/src/commands/requests.js +10 -15
- package/src/commands/review.js +2 -1
- package/src/commands/run.js +331 -245
- package/src/commands/walls.js +12 -5
- package/src/config-fields.js +305 -0
- package/src/config.js +114 -534
- package/src/controls.js +1 -1
- package/src/decide-request.js +37 -0
- package/src/denial-attribution.js +338 -0
- package/src/grants.js +1 -1
- package/src/help.js +6 -4
- package/src/index.js +1 -1
- package/src/inspect/enforce.js +1 -1
- package/src/inspect/files.js +1 -1
- package/src/inspect/home.js +1 -1
- package/src/inspect/inject.js +1 -1
- package/src/intake.js +3 -2
- package/src/keys.js +2 -2
- package/src/kinds.js +19 -0
- package/src/layout.js +1 -1
- package/src/log.js +3 -2
- package/src/mcp.js +1 -1
- package/src/notify-config.js +58 -0
- package/src/notify.js +4 -52
- package/src/owners.js +2 -2
- package/src/requests.js +9 -4
- package/src/rundir.js +2 -2
- package/src/runs.js +29 -10
- package/src/sandboxes/command.js +0 -2
- package/src/sandboxes/index.js +15 -2
- package/src/sandboxes/srt.js +1 -1
- package/src/sandboxes/tartaros.js +34 -6
- package/src/serve.js +11 -22
- package/src/spool.js +1 -1
- package/src/{srt.js → srt-settings.js} +5 -2
- package/src/state.js +25 -0
- package/src/suggest.js +26 -1
- package/src/surface.js +3 -2
- package/src/territory.js +20 -1
- package/src/toml.js +250 -0
- package/src/violations.js +16 -315
- package/src/walls.js +4 -2
- package/ui/index.html +35 -9
package/src/commands/run.js
CHANGED
|
@@ -12,7 +12,7 @@ import { finished } from "node:stream/promises";
|
|
|
12
12
|
import { constants as osConstants } from "node:os";
|
|
13
13
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { join, dirname, delimiter, resolve } from "node:path";
|
|
15
|
-
import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt.js";
|
|
15
|
+
import { settingsFor, roleHome, loopbackVia, agentOf, AGENTS, AGENT_HOMES } from "../srt-settings.js";
|
|
16
16
|
import { buildEnv } from "../env.js";
|
|
17
17
|
import { traceFields } from "../trace.js";
|
|
18
18
|
import { resolveKeys } from "../keys.js";
|
|
@@ -73,6 +73,46 @@ export function agentFor(mine, cmd) {
|
|
|
73
73
|
return given;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The egress refusals in a sandbox's own JSON-lines log (tartaros `--log`:
|
|
78
|
+
* `{axis: "network", outcome: "denied", target: "host:port", action}`), one per
|
|
79
|
+
* target with how many times: a client retrying a refused host is one wall, not
|
|
80
|
+
* fifty lines. Missing or unreadable: none, the run's own record still stands.
|
|
81
|
+
*/
|
|
82
|
+
export function brokerDenials(file) {
|
|
83
|
+
let text;
|
|
84
|
+
try { text = readFileSync(file, "utf8"); } catch { return []; }
|
|
85
|
+
const by = new Map();
|
|
86
|
+
for (const line of text.split("\n")) {
|
|
87
|
+
let e;
|
|
88
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
89
|
+
if (e?.axis !== "network" || e.outcome !== "denied" || typeof e.target !== "string") continue;
|
|
90
|
+
// A request refused on a terminated connection says `host:port (why)`: the
|
|
91
|
+
// host is the wall, the why (method and path) is its reason.
|
|
92
|
+
const m = /^(\S+) \((.*)\)$/.exec(e.target);
|
|
93
|
+
const target = (m ? m[1] : e.target).replace(/\/$/, "").slice(0, 300);
|
|
94
|
+
const reason = m ? m[2].slice(0, 300) : String(e.action ?? "denied");
|
|
95
|
+
const d = by.get(`${target}\u0000${reason}`) ?? { target, reason, times: 0 };
|
|
96
|
+
d.times++;
|
|
97
|
+
by.set(`${target}\u0000${reason}`, d);
|
|
98
|
+
}
|
|
99
|
+
return [...by.values()];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `--run-id <id>`: the launcher's own id for this run (an orchestrator's ULID),
|
|
104
|
+
* used instead of a random one wherever the run is named. Letters, digits, `-`
|
|
105
|
+
* and `_`, 64 at most: it becomes part of a directory name and of log lines.
|
|
106
|
+
*/
|
|
107
|
+
export function runIdOf(mine) {
|
|
108
|
+
const at = mine.lastIndexOf("--run-id");
|
|
109
|
+
if (at === -1) return null;
|
|
110
|
+
const id = mine[at + 1];
|
|
111
|
+
if (!id || !/^[A-Za-z0-9_-]{1,64}$/.test(id))
|
|
112
|
+
throw new Error(`--run-id ${id ?? ""}: letters, digits, "-" and "_" only, 64 at most`);
|
|
113
|
+
return id;
|
|
114
|
+
}
|
|
115
|
+
|
|
76
116
|
export async function run(config, argv) {
|
|
77
117
|
/**
|
|
78
118
|
* Ours before the `--`, theirs after it.
|
|
@@ -146,7 +186,11 @@ export async function run(config, argv) {
|
|
|
146
186
|
const notify = notifier(config);
|
|
147
187
|
// Everything that belongs to this run alone — socket, settings, run-file keys
|
|
148
188
|
// — in one private directory, with one id. See rundir.js.
|
|
149
|
-
const
|
|
189
|
+
const runId = runIdOf(mine);
|
|
190
|
+
const theRun = openRun(runId ? { id: runId } : undefined);
|
|
191
|
+
// How the log names this run: the launcher's id when it gave one, so its
|
|
192
|
+
// records and seisin's join on the same key; else the first 8 of seisin's.
|
|
193
|
+
const runLabel = runId ?? theRun.id.slice(0, 8);
|
|
150
194
|
/**
|
|
151
195
|
* From here on, every way out removes what this run made.
|
|
152
196
|
*
|
|
@@ -185,7 +229,7 @@ export async function run(config, argv) {
|
|
|
185
229
|
* The run's temp directory: one the role may write, never the runtime's.
|
|
186
230
|
*
|
|
187
231
|
* sandbox-runtime hands every child `/tmp/claude` as TMPDIR, and that
|
|
188
|
-
* directory is now denied (srt.js, SHARED_TMP). It takes CLAUDE_CODE_TMPDIR
|
|
232
|
+
* directory is now denied (srt-settings.js, SHARED_TMP). It takes CLAUDE_CODE_TMPDIR
|
|
189
233
|
* instead when set, so that is where the run's goes: the role's own tmp under
|
|
190
234
|
* `isolate = "home"`, else the parent's TMPDIR when the role may already write
|
|
191
235
|
* it, else one made for this run beside the runs root, granted to it alone. A caller's
|
|
@@ -214,7 +258,9 @@ export async function run(config, argv) {
|
|
|
214
258
|
const box = chooseSandbox(config, generated, { role });
|
|
215
259
|
if (box.error) throw box.error;
|
|
216
260
|
const { sandbox, bin } = box;
|
|
217
|
-
const
|
|
261
|
+
const handoffExempt = (config.roles[role].handoffExempt ?? []).map((e) => join(config.root, e));
|
|
262
|
+
const requests = config.roles[role].requests ?? [];
|
|
263
|
+
const settings = settingsOf(sandbox, generated, { machRegister, handoffExempt, requests });
|
|
218
264
|
// Per run, never per role: `.seisin/<role>.json` was one file for every run
|
|
219
265
|
// of that role, and two at once wrote each other's socket path into it.
|
|
220
266
|
const file = theRun.writeSettings(settings);
|
|
@@ -224,7 +270,7 @@ export async function run(config, argv) {
|
|
|
224
270
|
// The launcher's W3C trace, read from the parent's environment once: every line
|
|
225
271
|
// this run writes carries it, so the log joins the launcher's trace (trace.js).
|
|
226
272
|
const trace = traceFields(process.env);
|
|
227
|
-
const take = intake({ config, role, runId: theRun.id, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
|
|
273
|
+
const take = intake({ config, role, runId: theRun.id, label: runLabel, observe, settings, notify, chain: handed?.chain ?? null, expectDenied, trace });
|
|
228
274
|
// The channel is a unix socket on macOS, and a socket path is capped. Past
|
|
229
275
|
// the cap the listen dies on `EINVAL` and a path, which says nothing about
|
|
230
276
|
// TMPDIR — the one thing that decides how long it is.
|
|
@@ -240,141 +286,10 @@ export async function run(config, argv) {
|
|
|
240
286
|
writeFileSync(join(dirname(file), MACH_FILE), JSON.stringify({ register: machRegister }), { mode: 0o600 });
|
|
241
287
|
const wrap = (inner) => sandbox.wrap(bin, file, settings, inner, { role, machRegister });
|
|
242
288
|
|
|
243
|
-
|
|
244
|
-
// the
|
|
245
|
-
const {
|
|
246
|
-
env
|
|
247
|
-
env.SEISIN_CONFIG = config.path;
|
|
248
|
-
env[SOCK_ENV] = sockPath;
|
|
249
|
-
// The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
|
|
250
|
-
// from the decision checked above, and removed otherwise, so a value the
|
|
251
|
-
// parent happened to have never reaches a run that was not handed work.
|
|
252
|
-
delete env[CHAIN_ENV];
|
|
253
|
-
if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
|
|
254
|
-
/**
|
|
255
|
-
* The declared toolchain goes first on PATH, under `read = "territory"`.
|
|
256
|
-
*
|
|
257
|
-
* Declaring `~/.venvs/api` and then having `python` resolve to whatever the
|
|
258
|
-
* parent's PATH found first is not having the toolchain. Only in that mode:
|
|
259
|
-
* under "all" the PATH is the parent's, as it has always been.
|
|
260
|
-
*/
|
|
261
|
-
if (config.read === "territory") {
|
|
262
|
-
const bins = toolchainBins(config, config.roles[role], isDir);
|
|
263
|
-
if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Reference keys: resolved here, by the parent, before anything is spawned.
|
|
268
|
-
*
|
|
269
|
-
* The ordering is the security property, not a convenience. The provider
|
|
270
|
-
* command holds the vault's own credential — the keychain prompt, the
|
|
271
|
-
* 1Password session — and running it inside the sandbox would mean putting that
|
|
272
|
-
* credential in there too, which is the thing this feature exists to avoid.
|
|
273
|
-
* The confined side cannot reach the parent; the parent can reach the vault;
|
|
274
|
-
* so the parent resolves and hands over the result.
|
|
275
|
-
*
|
|
276
|
-
* A failure here stops the run. Not an empty value, not the file of the same
|
|
277
|
-
* name, not a skipped key: a role that starts without a credential it
|
|
278
|
-
* declared fails later, somewhere else, looking like something it is not.
|
|
279
|
-
*/
|
|
280
|
-
/**
|
|
281
|
-
* Each resolution is a line in the log: which key, which provider, which
|
|
282
|
-
* mode, whether it resolved. Never the value — the log is the record of what
|
|
283
|
-
* the policy did, and a secret in it would be a second plaintext copy with
|
|
284
|
-
* worse access control than the first. Shaped like the canary's line, a
|
|
285
|
-
* run-level event beside the verdicts, so readers that count verdicts skip it.
|
|
286
|
-
*/
|
|
287
|
-
const resolved = resolveKeys(config, config.roles[role], {
|
|
288
|
-
record: ({ entry, mode, ok }) => append(logPath(config.root), {
|
|
289
|
-
// The variable's name and the provider, never the reference: a reference
|
|
290
|
-
// can carry what it points at (a provider that echoes it, a path that
|
|
291
|
-
// names the secret), and the log is read by more people than the policy.
|
|
292
|
-
event: "key", role, run: theRun.id.slice(0, 8), ...trace, key: entry.name, provider: entry.scheme, mode, ok,
|
|
293
|
-
}),
|
|
294
|
-
});
|
|
295
|
-
const runFileKeys = theRun.keys;
|
|
296
|
-
if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
|
|
297
|
-
for (const { entry, mode, value } of resolved) {
|
|
298
|
-
if (mode === "env") { env[entry.name] = value; continue; }
|
|
299
|
-
/**
|
|
300
|
-
* `inject`: the real value goes into the environment of `srt` — not the
|
|
301
|
-
* agent's. The settings this run already generated declare it under
|
|
302
|
-
* `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
|
|
303
|
-
* value from its own environment, hands the sandboxed child a sentinel, and
|
|
304
|
-
* substitutes the real value back only on egress to the credential's hosts.
|
|
305
|
-
* `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
|
|
306
|
-
* This mode is Linux-only for now — see injectGate in keys.js. The value is
|
|
307
|
-
* still masked on stdout/stderr below, so a sentinel the agent reflects into
|
|
308
|
-
* its own output is covered too.
|
|
309
|
-
*/
|
|
310
|
-
if (mode === "inject") { env[entry.name] = value; continue; }
|
|
311
|
-
// `run-file`: the value lands in the per-run directory this process already
|
|
312
|
-
// makes for the spool and already removes on exit, so its lifetime is the
|
|
313
|
-
// run's by construction rather than by a cleanup somebody has to remember.
|
|
314
|
-
// The variable carries the PATH, which is the convention every tool that
|
|
315
|
-
// wants a secret from a file already reads (`_FILE`), and it means the
|
|
316
|
-
// value itself is not in the environment of a process that may print it.
|
|
317
|
-
const file = join(runFileKeys, entry.name);
|
|
318
|
-
writeFileSync(file, value, { mode: 0o600 });
|
|
319
|
-
/**
|
|
320
|
-
* Both names, because the world has two conventions and neither is ours.
|
|
321
|
-
*
|
|
322
|
-
* `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
|
|
323
|
-
* take the value in `<NAME>`. But a large family of tools already expects
|
|
324
|
-
* a PATH in the plain variable — `KUBECONFIG`,
|
|
325
|
-
* `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
|
|
326
|
-
* for those, `KUBECONFIG_FILE` is a name nothing reads.
|
|
327
|
-
*
|
|
328
|
-
* Found by running `kubectl` against it rather than reasoning about it:
|
|
329
|
-
* the file was there and correct, and the variable it was announced under
|
|
330
|
-
* was one kubectl has never heard of.
|
|
331
|
-
*
|
|
332
|
-
* Setting both costs nothing and leaks nothing: in `run-file` mode the
|
|
333
|
-
* value is deliberately not in the environment, so both variables hold a
|
|
334
|
-
* path and neither holds a secret.
|
|
335
|
-
*/
|
|
336
|
-
env[entry.name] = file;
|
|
337
|
-
env[`${entry.name}_FILE`] = file;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Isolated mode: point the toolchain at a home of this role's own.
|
|
342
|
-
*
|
|
343
|
-
* Granting `~/.claude` to every role is what made "its own folders" only
|
|
344
|
-
* true of the repo. A CLI that keeps session state — or a token — under the
|
|
345
|
-
* real home put it somewhere every other role could read and write.
|
|
346
|
-
*
|
|
347
|
-
* The XDG variables are set as well as HOME, because a tool that reads
|
|
348
|
-
* `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
|
|
349
|
-
* that failure is silent: the run works and the isolation does not.
|
|
350
|
-
*/
|
|
351
|
-
// Only the `home` level moves HOME. `credentials` closes the places
|
|
352
|
-
// credentials live and leaves the home where it is — which is what keeps the
|
|
353
|
-
// agent logged in, since on macOS its credential is in the login keychain and
|
|
354
|
-
// the keychain is found through HOME.
|
|
355
|
-
if (config.isolate === "home" || config.isolate === true) {
|
|
356
|
-
const home = roleHome(config, role);
|
|
357
|
-
for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
|
|
358
|
-
join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
|
|
359
|
-
mkdirSync(d, { recursive: true });
|
|
360
|
-
env.HOME = home;
|
|
361
|
-
env.XDG_CONFIG_HOME = join(home, ".config");
|
|
362
|
-
env.XDG_DATA_HOME = join(home, ".local", "share");
|
|
363
|
-
env.XDG_STATE_HOME = join(home, ".local", "state");
|
|
364
|
-
env.XDG_CACHE_HOME = join(home, ".cache");
|
|
365
|
-
env.TMPDIR = join(home, "tmp");
|
|
366
|
-
// zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
|
|
367
|
-
// /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
|
|
368
|
-
// failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
|
|
369
|
-
env.TMPPREFIX = join(home, "tmp", "zsh");
|
|
370
|
-
}
|
|
371
|
-
env.TMPDIR = runTmp;
|
|
372
|
-
env.CLAUDE_CODE_TMPDIR = runTmp;
|
|
373
|
-
// zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
|
|
374
|
-
// the isolated-home note above. Same answer for every run now.
|
|
375
|
-
env.TMPPREFIX = join(runTmp, "zsh");
|
|
376
|
-
delete env.CLAUDE_TMPDIR;
|
|
377
|
-
if (observe) env.SEISIN_OBSERVE = "1";
|
|
289
|
+
const { env, dropped } = prepareEnvironment({ config, role, sockPath, handed, isDir });
|
|
290
|
+
// Resolve in the parent, then deliver, before the canary or agent can start.
|
|
291
|
+
const resolved = deliverCredentials({ config, role, runLabel, trace, theRun, env });
|
|
292
|
+
prepareRunHome({ config, role, env, runTmp, observe });
|
|
378
293
|
if (mine.includes("--debug-env")) err(`${C.dim}seisin: dropped ${dropped.join(" ")}${C.off}\n`);
|
|
379
294
|
|
|
380
295
|
/**
|
|
@@ -493,7 +408,7 @@ export async function run(config, argv) {
|
|
|
493
408
|
});
|
|
494
409
|
if (!probe.ok) {
|
|
495
410
|
append(logPath(config.root), {
|
|
496
|
-
event: "canary", role, run:
|
|
411
|
+
event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: false, reasons: probe.failed,
|
|
497
412
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
498
413
|
});
|
|
499
414
|
if (probe.exposed) {
|
|
@@ -512,7 +427,7 @@ export async function run(config, argv) {
|
|
|
512
427
|
process.exit(CANARY_EXIT);
|
|
513
428
|
}
|
|
514
429
|
append(logPath(config.root), {
|
|
515
|
-
event: "canary", role, run:
|
|
430
|
+
event: "canary", role, run: runLabel, ...trace, agent: agent ?? null, confined: true, reasons: [],
|
|
516
431
|
...(sandbox.name === DEFAULT_SANDBOX ? {} : { sandbox: sandbox.name }),
|
|
517
432
|
});
|
|
518
433
|
// Said once, only when it is not the default: which sandbox holds this run
|
|
@@ -530,7 +445,7 @@ export async function run(config, argv) {
|
|
|
530
445
|
`${handed.chain.depth} of ${MAX_DEPTH}${C.off}\n`);
|
|
531
446
|
}
|
|
532
447
|
|
|
533
|
-
const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${
|
|
448
|
+
const boxed = ["env", ...restoreTmp, `SEISIN_RUN_ID=${runLabel}`, ...loopbackVia(config.roles[role], cmd, sandbox.name)];
|
|
534
449
|
// The tag is sandbox-runtime's: under any other sandbox there is none to
|
|
535
450
|
// match, and attribution goes by the process tree alone (violations.js).
|
|
536
451
|
denials = watchDenials(take.fromKernel, { argv: sandbox.tagsDenials ? boxed : null });
|
|
@@ -552,7 +467,12 @@ export async function run(config, argv) {
|
|
|
552
467
|
const nested = nestedSandboxWarning(cmd);
|
|
553
468
|
if (nested) err(`${C.yellow}seisin: ${nested}${C.off}\n`);
|
|
554
469
|
|
|
555
|
-
|
|
470
|
+
// The sandbox's own record of what its egress broker refused (tartaros
|
|
471
|
+
// `--log`), in this run's private directory: read back into seisin's log when
|
|
472
|
+
// the run ends, so a denied host is a wall like any other instead of a line
|
|
473
|
+
// on stderr. Only the agent's run gets it, not the canary's.
|
|
474
|
+
const brokerLog = join(theRun.dir, "sandbox-log.jsonl");
|
|
475
|
+
const [runner, ...runnerArgs] = sandbox.wrap(bin, file, settings, boxed, { role, machRegister, log: brokerLog });
|
|
556
476
|
const child = spawn(runner, runnerArgs, {
|
|
557
477
|
stdio: outStream ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
558
478
|
env,
|
|
@@ -590,17 +510,6 @@ export async function run(config, argv) {
|
|
|
590
510
|
process.on(sig, () => { stoppedBy ??= sig; try { child.kill(sig); } catch {} });
|
|
591
511
|
if (outStream) { child.stdout.pipe(outStream); child.stderr.pipe(errStream); }
|
|
592
512
|
|
|
593
|
-
// The exit code is the child's, and the output has to be all the way out
|
|
594
|
-
// before we leave. Both halves were wrong once and neither was loud:
|
|
595
|
-
//
|
|
596
|
-
// - Calling exit() the moment the child exits discards whatever is still in
|
|
597
|
-
// the redaction stream, so the command looks like it produced nothing.
|
|
598
|
-
// - Waiting for `finish` but subscribing AFTER it fired left nothing to call
|
|
599
|
-
// exit: first the process drifted out with status 0 (every failure
|
|
600
|
-
// reported as a success), later the 2-second guard below caught it and
|
|
601
|
-
// every keyed run paid two seconds. `finished()` has no such window.
|
|
602
|
-
//
|
|
603
|
-
// Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
|
|
604
513
|
/**
|
|
605
514
|
* The status is worked out when the run is about to leave, not when the
|
|
606
515
|
* child exits. A Ctrl-C reaches the runtime and seisin at once; when the
|
|
@@ -617,96 +526,10 @@ export async function run(config, argv) {
|
|
|
617
526
|
: interrupted && !code ? 130
|
|
618
527
|
: code ?? 0;
|
|
619
528
|
|
|
620
|
-
child.on("exit",
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
// where a pipeline would swallow it.
|
|
625
|
-
// Close the spool before reading the queue: a line the hook sent on the
|
|
626
|
-
// agent's last turn may still be in flight, and reporting a queue that is
|
|
627
|
-
// one entry behind is how a request goes unnoticed for a day.
|
|
628
|
-
audit.close();
|
|
629
|
-
// Same reason, one layer further out: the kernel writes its line through a
|
|
630
|
-
// separate process, so the last denial of a run can still be in flight
|
|
631
|
-
// here. This is the only wait the mechanism adds and it is per run — it
|
|
632
|
-
// returns as soon as the stream goes quiet, so a run that was denied
|
|
633
|
-
// nothing pays a tick.
|
|
634
|
-
await denials.close({ drain: DRAIN_MS });
|
|
635
|
-
// The run made this directory, so the run removes it.
|
|
636
|
-
cleanup();
|
|
637
|
-
|
|
638
|
-
// Say what was dropped rather than only what was kept. A filter nobody can
|
|
639
|
-
// see is indistinguishable from a monitor that is not working, and this one
|
|
640
|
-
// drops the majority of what the kernel says on a busy run.
|
|
641
|
-
// Only when something was actually recorded. A run where every denial was
|
|
642
|
-
// filtered out has nothing to contextualise, and `0 recorded, 1 outside the
|
|
643
|
-
// policy's paths` is a line that answers a question nobody asked — the
|
|
644
|
-
// counts exist to explain a filter, not to announce that one ran.
|
|
645
|
-
const { attributed, dropped } = denials.stats;
|
|
646
|
-
// The one loss the filter above does not explain: the system log could not
|
|
647
|
-
// keep up and threw lines away before seisin saw them. The boundary held;
|
|
648
|
-
// the record did not. Said every time, and written down, because a denial
|
|
649
|
-
// that never arrived files no request and nothing else would show it.
|
|
650
|
-
if (dropped) {
|
|
651
|
-
append(logPath(config.root), { event: "log-dropped", role, run: theRun.id.slice(0, 8), ...trace, batches: dropped });
|
|
652
|
-
err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
|
|
653
|
-
`kernel denials may be missing from the log and the queue. ` +
|
|
654
|
-
`\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
|
|
655
|
-
}
|
|
656
|
-
const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
|
|
657
|
-
// A browser that died at start on its Mach rendezvous: the crash the agent
|
|
658
|
-
// saw does not say why, and this is the line that does.
|
|
659
|
-
for (const b of browsers)
|
|
660
|
-
err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
|
|
661
|
-
`exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
|
|
662
|
-
// A save refused at its temporary file, beside a file this role may write:
|
|
663
|
-
// the file was not saved, and nothing was queued because there is nothing
|
|
664
|
-
// to grant (the temporary name changes every time). Said whether or not
|
|
665
|
-
// anything else was recorded, because the agent's error said neither.
|
|
666
|
-
if (atomic.length) {
|
|
667
|
-
const files = [...new Set(atomic)];
|
|
668
|
-
err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
|
|
669
|
-
`${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
|
|
670
|
-
`(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
|
|
671
|
-
}
|
|
672
|
-
const recorded = attributed - offPolicy - walks;
|
|
673
|
-
if (recorded > 0)
|
|
674
|
-
err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
|
|
675
|
-
`${walks ? `, ${walks} directory scan(s)` : ""}` +
|
|
676
|
-
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
|
|
677
|
-
// Claude Code's plugin updater, refused correctly: said as expected so
|
|
678
|
-
// it does not read as the run failing (kinds.js expectedOf).
|
|
679
|
-
(expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
|
|
680
|
-
// The caller's own probes: denied as it asked, and nothing to grant.
|
|
681
|
-
(probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
|
|
682
|
-
|
|
683
|
-
// Only what this run added, with the numbers `grant` takes, and one line
|
|
684
|
-
// for the rest. The whole queue after every run buried the new request
|
|
685
|
-
// under the ones already seen; nothing new, nothing said.
|
|
686
|
-
const queue = queueOf(config);
|
|
687
|
-
const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
|
|
688
|
-
if (added.length) err(renderAdded(added, queue.length - added.length));
|
|
689
|
-
const failed = await notify.settle();
|
|
690
|
-
if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
|
|
691
|
-
// One more turn of the loop, so a signal that arrived with the child's exit
|
|
692
|
-
// has run its handler before the status is decided.
|
|
693
|
-
await new Promise((r) => setImmediate(r));
|
|
694
|
-
const status = statusOf(code, signal);
|
|
695
|
-
if (!outStream) return process.exit(status);
|
|
696
|
-
// `finished()` and not `once("finish")`, because the event is usually over
|
|
697
|
-
// before this line runs: `child.stdout.pipe(outStream)` ends the redactor
|
|
698
|
-
// itself when the child's stdout closes, so the listener was attached to an
|
|
699
|
-
// event that had already fired and the 2-second guard was the only way out.
|
|
700
|
-
// Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
|
|
701
|
-
// `finished()` settles at once for a stream that is already done.
|
|
702
|
-
const guard = setTimeout(() => process.exit(status), 2000);
|
|
703
|
-
// A child's exit is not its pipes' EOF. Let pipe() end the redactors
|
|
704
|
-
// after the remaining stdout/stderr arrives; ending them here lost that
|
|
705
|
-
// output. The guard still bounds a descendant that keeps a pipe open.
|
|
706
|
-
await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
|
|
707
|
-
clearTimeout(guard);
|
|
708
|
-
process.exit(status);
|
|
709
|
-
});
|
|
529
|
+
child.on("exit", (code, signal) => finishRun({
|
|
530
|
+
config, role, runLabel, trace, audit, denials, brokerLog, sandbox,
|
|
531
|
+
cleanup, take, before, notify, statusOf, outStream, errStream,
|
|
532
|
+
}, code, signal));
|
|
710
533
|
// Not a `throw`: in a listener it is an uncaught exception — exit 1, a
|
|
711
534
|
// stack trace, and none of the cleanup. Said the way cli.js says any other
|
|
712
535
|
// failure, with its code; the `exit` handler above removes the run.
|
|
@@ -716,6 +539,269 @@ export async function run(config, argv) {
|
|
|
716
539
|
});
|
|
717
540
|
}
|
|
718
541
|
|
|
542
|
+
// Keep environment filtering ahead of credential resolution: providers run in
|
|
543
|
+
// the parent, and only the sandbox receives the resulting environment.
|
|
544
|
+
function prepareEnvironment({ config, role, sockPath, handed, isDir }) {
|
|
545
|
+
// The hook runs inside the child and has to know which role it is. These are
|
|
546
|
+
// the only variables seisin injects, and none carries a secret.
|
|
547
|
+
const { env, dropped } = buildEnv(process.env, config.roles[role]);
|
|
548
|
+
env.SEISIN_ROLE = role;
|
|
549
|
+
env.SEISIN_CONFIG = config.path;
|
|
550
|
+
// A policy read with --policy is rooted where the run started, not beside the
|
|
551
|
+
// file; the hook must agree, or it would judge paths against the wrong repo.
|
|
552
|
+
if (config.root !== dirname(config.path)) env.SEISIN_ROOT = config.root;
|
|
553
|
+
else delete env.SEISIN_ROOT;
|
|
554
|
+
env[SOCK_ENV] = sockPath;
|
|
555
|
+
// The handoff chain, for the hook's words only (handoff.js, CHAIN_ENV): set
|
|
556
|
+
// from the decision checked above, and removed otherwise, so a value the
|
|
557
|
+
// parent happened to have never reaches a run that was not handed work.
|
|
558
|
+
delete env[CHAIN_ENV];
|
|
559
|
+
if (handed) env[CHAIN_ENV] = JSON.stringify(handed.chain);
|
|
560
|
+
/**
|
|
561
|
+
* The declared toolchain goes first on PATH, under `read = "territory"`.
|
|
562
|
+
*
|
|
563
|
+
* Declaring `~/.venvs/api` and then having `python` resolve to whatever the
|
|
564
|
+
* parent's PATH found first is not having the toolchain. Only in that mode:
|
|
565
|
+
* under "all" the PATH is the parent's, as it has always been.
|
|
566
|
+
*/
|
|
567
|
+
if (config.read === "territory") {
|
|
568
|
+
const bins = toolchainBins(config, config.roles[role], isDir);
|
|
569
|
+
if (bins.length) env.PATH = [...bins, ...(env.PATH ? [env.PATH] : [])].join(delimiter);
|
|
570
|
+
}
|
|
571
|
+
return { env, dropped };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function deliverCredentials({ config, role, runLabel, trace, theRun, env }) {
|
|
575
|
+
/**
|
|
576
|
+
* Reference keys: resolved here, by the parent, before anything is spawned.
|
|
577
|
+
*
|
|
578
|
+
* The ordering is the security property, not a convenience. The provider
|
|
579
|
+
* command holds the vault's own credential — the keychain prompt, the
|
|
580
|
+
* 1Password session — and running it inside the sandbox would mean putting that
|
|
581
|
+
* credential in there too, which is the thing this feature exists to avoid.
|
|
582
|
+
* The confined side cannot reach the parent; the parent can reach the vault;
|
|
583
|
+
* so the parent resolves and hands over the result.
|
|
584
|
+
*
|
|
585
|
+
* A failure here stops the run. Not an empty value, not the file of the same
|
|
586
|
+
* name, not a skipped key: a role that starts without a credential it
|
|
587
|
+
* declared fails later, somewhere else, looking like something it is not.
|
|
588
|
+
*/
|
|
589
|
+
/**
|
|
590
|
+
* Each resolution is a line in the log: which key, which provider, which
|
|
591
|
+
* mode, whether it resolved. Never the value — the log is the record of what
|
|
592
|
+
* the policy did, and a secret in it would be a second plaintext copy with
|
|
593
|
+
* worse access control than the first. Shaped like the canary's line, a
|
|
594
|
+
* run-level event beside the verdicts, so readers that count verdicts skip it.
|
|
595
|
+
*/
|
|
596
|
+
const resolved = resolveKeys(config, config.roles[role], {
|
|
597
|
+
record: ({ entry, mode, ok }) => append(logPath(config.root), {
|
|
598
|
+
// The variable's name and the provider, never the reference: a reference
|
|
599
|
+
// can carry what it points at (a provider that echoes it, a path that
|
|
600
|
+
// names the secret), and the log is read by more people than the policy.
|
|
601
|
+
event: "key", role, run: runLabel, ...trace, key: entry.name, provider: entry.scheme, mode, ok,
|
|
602
|
+
}),
|
|
603
|
+
});
|
|
604
|
+
const runFileKeys = theRun.keys;
|
|
605
|
+
if (resolved.some((r) => r.mode === "run-file")) mkdirSync(runFileKeys, { recursive: true, mode: 0o700 });
|
|
606
|
+
for (const { entry, mode, value } of resolved) {
|
|
607
|
+
if (mode === "env") { env[entry.name] = value; continue; }
|
|
608
|
+
/**
|
|
609
|
+
* `inject`: the real value goes into the environment of `srt` — not the
|
|
610
|
+
* agent's. The settings this run already generated declare it under
|
|
611
|
+
* `credentials.envVars` with `mode: "mask"`, so the runtime reads the real
|
|
612
|
+
* value from its own environment, hands the sandboxed child a sentinel, and
|
|
613
|
+
* substitutes the real value back only on egress to the credential's hosts.
|
|
614
|
+
* `srt` runs outside the sandbox, and the sandboxed child gets the sentinel.
|
|
615
|
+
* This mode is Linux-only for now — see injectGate in keys.js. The value is
|
|
616
|
+
* still masked on stdout/stderr below, so a sentinel the agent reflects into
|
|
617
|
+
* its own output is covered too.
|
|
618
|
+
*/
|
|
619
|
+
if (mode === "inject") { env[entry.name] = value; continue; }
|
|
620
|
+
// `run-file`: the value lands in the per-run directory this process already
|
|
621
|
+
// makes for the spool and already removes on exit, so its lifetime is the
|
|
622
|
+
// run's by construction rather than by a cleanup somebody has to remember.
|
|
623
|
+
// The variable carries the PATH, which is the convention every tool that
|
|
624
|
+
// wants a secret from a file already reads (`_FILE`), and it means the
|
|
625
|
+
// value itself is not in the environment of a process that may print it.
|
|
626
|
+
const file = join(runFileKeys, entry.name);
|
|
627
|
+
writeFileSync(file, value, { mode: 0o600 });
|
|
628
|
+
/**
|
|
629
|
+
* Both names, because the world has two conventions and neither is ours.
|
|
630
|
+
*
|
|
631
|
+
* `<NAME>_FILE` is the Docker-secrets shape, read by tools that normally
|
|
632
|
+
* take the value in `<NAME>`. But a large family of tools already expects
|
|
633
|
+
* a PATH in the plain variable — `KUBECONFIG`,
|
|
634
|
+
* `GOOGLE_APPLICATION_CREDENTIALS`, `AWS_SHARED_CREDENTIALS_FILE` — and
|
|
635
|
+
* for those, `KUBECONFIG_FILE` is a name nothing reads.
|
|
636
|
+
*
|
|
637
|
+
* Found by running `kubectl` against it rather than reasoning about it:
|
|
638
|
+
* the file was there and correct, and the variable it was announced under
|
|
639
|
+
* was one kubectl has never heard of.
|
|
640
|
+
*
|
|
641
|
+
* Setting both costs nothing and leaks nothing: in `run-file` mode the
|
|
642
|
+
* value is deliberately not in the environment, so both variables hold a
|
|
643
|
+
* path and neither holds a secret.
|
|
644
|
+
*/
|
|
645
|
+
env[entry.name] = file;
|
|
646
|
+
env[`${entry.name}_FILE`] = file;
|
|
647
|
+
}
|
|
648
|
+
return resolved;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Home and tmp overrides follow key delivery, as they did in the inline setup.
|
|
652
|
+
function prepareRunHome({ config, role, env, runTmp, observe }) {
|
|
653
|
+
/**
|
|
654
|
+
* Isolated mode: point the toolchain at a home of this role's own.
|
|
655
|
+
*
|
|
656
|
+
* Granting `~/.claude` to every role is what made "its own folders" only
|
|
657
|
+
* true of the repo. A CLI that keeps session state — or a token — under the
|
|
658
|
+
* real home put it somewhere every other role could read and write.
|
|
659
|
+
*
|
|
660
|
+
* The XDG variables are set as well as HOME, because a tool that reads
|
|
661
|
+
* `XDG_CACHE_HOME` directly would otherwise still land in the real one, and
|
|
662
|
+
* that failure is silent: the run works and the isolation does not.
|
|
663
|
+
*/
|
|
664
|
+
// Only the `home` level moves HOME. `credentials` closes the places
|
|
665
|
+
// credentials live and leaves the home where it is — which is what keeps the
|
|
666
|
+
// agent logged in, since on macOS its credential is in the login keychain and
|
|
667
|
+
// the keychain is found through HOME.
|
|
668
|
+
if (config.isolate === "home" || config.isolate === true) {
|
|
669
|
+
const home = roleHome(config, role);
|
|
670
|
+
for (const d of [home, join(home, ".config"), join(home, ".local", "share"),
|
|
671
|
+
join(home, ".local", "state"), join(home, ".cache"), join(home, "tmp")])
|
|
672
|
+
mkdirSync(d, { recursive: true });
|
|
673
|
+
env.HOME = home;
|
|
674
|
+
env.XDG_CONFIG_HOME = join(home, ".config");
|
|
675
|
+
env.XDG_DATA_HOME = join(home, ".local", "share");
|
|
676
|
+
env.XDG_STATE_HOME = join(home, ".local", "state");
|
|
677
|
+
env.XDG_CACHE_HOME = join(home, ".cache");
|
|
678
|
+
env.TMPDIR = join(home, "tmp");
|
|
679
|
+
// zsh writes its heredocs to $TMPPREFIX, not $TMPDIR, and defaults it to
|
|
680
|
+
// /tmp/zsh — which the role cannot write. Every heredoc in every zsh script
|
|
681
|
+
// failed with the role's own tmp a directory away (measured by an orchestrator running zsh scripts).
|
|
682
|
+
env.TMPPREFIX = join(home, "tmp", "zsh");
|
|
683
|
+
}
|
|
684
|
+
env.TMPDIR = runTmp;
|
|
685
|
+
env.CLAUDE_CODE_TMPDIR = runTmp;
|
|
686
|
+
// zsh writes heredocs to $TMPPREFIX (default /tmp/zsh), not $TMPDIR: see
|
|
687
|
+
// the isolated-home note above. Same answer for every run now.
|
|
688
|
+
env.TMPPREFIX = join(runTmp, "zsh");
|
|
689
|
+
delete env.CLAUDE_TMPDIR;
|
|
690
|
+
if (observe) env.SEISIN_OBSERVE = "1";
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Do not pass resolved keys or env to closing: it needs audit/output state.
|
|
694
|
+
// statusOf reads live signal state after the drains, not a snapshot at exit.
|
|
695
|
+
async function finishRun({
|
|
696
|
+
config, role, runLabel, trace, audit, denials, brokerLog, sandbox,
|
|
697
|
+
cleanup, take, before, notify, statusOf, outStream, errStream,
|
|
698
|
+
}, code, signal) {
|
|
699
|
+
// The exit code is the child's, and the output has to be all the way out
|
|
700
|
+
// before we leave. Both halves were wrong once and neither was loud:
|
|
701
|
+
//
|
|
702
|
+
// - Calling exit() the moment the child exits discards whatever is still in
|
|
703
|
+
// the redaction stream, so the command looks like it produced nothing.
|
|
704
|
+
// - Waiting for `finish` but subscribing AFTER it fired left nothing to call
|
|
705
|
+
// exit: first the process drifted out with status 0 (every failure
|
|
706
|
+
// reported as a success), later the 2-second guard below caught it and
|
|
707
|
+
// every keyed run paid two seconds. `finished()` has no such window.
|
|
708
|
+
//
|
|
709
|
+
// Keep a real timer (not unref'd, or it cannot save anything) as the ceiling.
|
|
710
|
+
// Drain and record before cleanup removes the run's broker log and keys.
|
|
711
|
+
// The notice rides on what you are already looking at. A queue nobody opens
|
|
712
|
+
// is not human-in-the-loop, and this is the terminal that just showed you
|
|
713
|
+
// the denial — so it goes to stderr, beside it, not into the agent's stdout
|
|
714
|
+
// where a pipeline would swallow it.
|
|
715
|
+
// Close the spool before reading the queue: a line the hook sent on the
|
|
716
|
+
// agent's last turn may still be in flight, and reporting a queue that is
|
|
717
|
+
// one entry behind is how a request goes unnoticed for a day.
|
|
718
|
+
audit.close();
|
|
719
|
+
// Same reason, one layer further out: the kernel writes its line through a
|
|
720
|
+
// separate process, so the last denial of a run can still be in flight
|
|
721
|
+
// here. This is the only wait the mechanism adds and it is per run — it
|
|
722
|
+
// returns as soon as the stream goes quiet, so a run that was denied
|
|
723
|
+
// nothing pays a tick.
|
|
724
|
+
await denials.close({ drain: DRAIN_MS });
|
|
725
|
+
// Before the directory goes: the hosts the sandbox's broker refused.
|
|
726
|
+
for (const d of brokerDenials(brokerLog))
|
|
727
|
+
append(logPath(config.root), { role, run: runLabel, ...trace, tool: "broker", source: sandbox.name, action: "connect",
|
|
728
|
+
kind: "network", target: d.target, verdict: "denied", owners: [], reason: d.reason, times: d.times });
|
|
729
|
+
// The run made this directory, so the run removes it.
|
|
730
|
+
cleanup();
|
|
731
|
+
|
|
732
|
+
// Say what was dropped rather than only what was kept. A filter nobody can
|
|
733
|
+
// see is indistinguishable from a monitor that is not working, and this one
|
|
734
|
+
// drops the majority of what the kernel says on a busy run.
|
|
735
|
+
// Only when something was actually recorded. A run where every denial was
|
|
736
|
+
// filtered out has nothing to contextualise, and `0 recorded, 1 outside the
|
|
737
|
+
// policy's paths` is a line that answers a question nobody asked — the
|
|
738
|
+
// counts exist to explain a filter, not to announce that one ran.
|
|
739
|
+
const { attributed, dropped } = denials.stats;
|
|
740
|
+
// The one loss the filter above does not explain: the system log could not
|
|
741
|
+
// keep up and threw lines away before seisin saw them. The boundary held;
|
|
742
|
+
// the record did not. Said every time, and written down, because a denial
|
|
743
|
+
// that never arrived files no request and nothing else would show it.
|
|
744
|
+
if (dropped) {
|
|
745
|
+
append(logPath(config.root), { event: "log-dropped", role, run: runLabel, ...trace, batches: dropped });
|
|
746
|
+
err(`${C.yellow}seisin: the system log dropped ${dropped} batch(es) of messages during this run: ` +
|
|
747
|
+
`kernel denials may be missing from the log and the queue. ` +
|
|
748
|
+
`\`seisin check --system-log\` says what is flooding it.${C.off}\n`);
|
|
749
|
+
}
|
|
750
|
+
const { offPolicy, walks, expected, probes, atomic, browsers } = take.stats;
|
|
751
|
+
// A browser that died at start on its Mach rendezvous: the crash the agent
|
|
752
|
+
// saw does not say why, and this is the line that does.
|
|
753
|
+
for (const b of browsers)
|
|
754
|
+
err(`${C.yellow}seisin: ${role} started ${b} and the sandbox denied the Mach service it registers at start, so it ` +
|
|
755
|
+
`exited. Add browsers = ["${b}"] to [roles.${role}] in seisin.toml (docs/browsers.md).${C.off}\n`);
|
|
756
|
+
// A save refused at its temporary file, beside a file this role may write:
|
|
757
|
+
// the file was not saved, and nothing was queued because there is nothing
|
|
758
|
+
// to grant (the temporary name changes every time). Said whether or not
|
|
759
|
+
// anything else was recorded, because the agent's error said neither.
|
|
760
|
+
if (atomic.length) {
|
|
761
|
+
const files = [...new Set(atomic)];
|
|
762
|
+
err(`${C.yellow}seisin: ${files.length} file(s) ${role} may write were not saved: the save's temporary file beside ` +
|
|
763
|
+
`${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""} was denied ` +
|
|
764
|
+
`(Claude Code's Edit and Write). \`seisin check ${role}\` says whether a grant can fix it.${C.off}\n`);
|
|
765
|
+
}
|
|
766
|
+
const recorded = attributed - offPolicy - walks;
|
|
767
|
+
if (recorded > 0)
|
|
768
|
+
err(`${C.dim}seisin: ${recorded} kernel denial(s) recorded` +
|
|
769
|
+
`${walks ? `, ${walks} directory scan(s)` : ""}` +
|
|
770
|
+
`${offPolicy ? `, ${offPolicy} outside the policy's paths` : ""}${C.off}\n` +
|
|
771
|
+
// Claude Code's plugin updater, refused correctly: said as expected so
|
|
772
|
+
// it does not read as the run failing (kinds.js expectedOf).
|
|
773
|
+
(expected ? `${C.dim}seisin: ${expected} of them expected: ${EXPECTED.plugins}${C.off}\n` : "") +
|
|
774
|
+
// The caller's own probes: denied as it asked, and nothing to grant.
|
|
775
|
+
(probes ? `${C.dim}seisin: ${probes} of them expected by the caller (--expect-denied) — nothing queued${C.off}\n` : ""));
|
|
776
|
+
|
|
777
|
+
// Only what this run added, with the numbers `grant` takes, and one line
|
|
778
|
+
// for the rest. The whole queue after every run buried the new request
|
|
779
|
+
// under the ones already seen; nothing new, nothing said.
|
|
780
|
+
const queue = queueOf(config);
|
|
781
|
+
const added = queue.map((q, i) => ({ ...q, n: i + 1 })).filter((q) => !before.has(q.key));
|
|
782
|
+
if (added.length) err(renderAdded(added, queue.length - added.length));
|
|
783
|
+
const failed = await notify.settle();
|
|
784
|
+
if (failed.length) err(`${C.yellow}seisin: could not notify about a new request (${failed[0]})${C.off}\n`);
|
|
785
|
+
// One more turn of the loop, so a signal that arrived with the child's exit
|
|
786
|
+
// has run its handler before the status is decided.
|
|
787
|
+
await new Promise((r) => setImmediate(r));
|
|
788
|
+
const status = statusOf(code, signal);
|
|
789
|
+
if (!outStream) return process.exit(status);
|
|
790
|
+
// `finished()` and not `once("finish")`, because the event is usually over
|
|
791
|
+
// before this line runs: `child.stdout.pipe(outStream)` ends the redactor
|
|
792
|
+
// itself when the child's stdout closes, so the listener was attached to an
|
|
793
|
+
// event that had already fired and the 2-second guard was the only way out.
|
|
794
|
+
// Every run with a key paid it — measured 3.0 s against 1.2 s without keys.
|
|
795
|
+
// `finished()` settles at once for a stream that is already done.
|
|
796
|
+
const guard = setTimeout(() => process.exit(status), 2000);
|
|
797
|
+
// A child's exit is not its pipes' EOF. Let pipe() end the redactors
|
|
798
|
+
// after the remaining stdout/stderr arrives; ending them here lost that
|
|
799
|
+
// output. The guard still bounds a descendant that keeps a pipe open.
|
|
800
|
+
await Promise.all([finished(outStream), finished(errStream)]).catch(() => {});
|
|
801
|
+
clearTimeout(guard);
|
|
802
|
+
process.exit(status);
|
|
803
|
+
}
|
|
804
|
+
|
|
719
805
|
/**
|
|
720
806
|
* `--handoff <id>`: this run is the role a pending request was handed to.
|
|
721
807
|
*
|