seisin 0.6.1 → 0.6.3

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.
@@ -150,6 +150,9 @@ export const srt = {
150
150
  locate: () => resolveSrt(),
151
151
  missing: () => `sandbox runtime not found. ${installHint()}`,
152
152
  unmet: () => [],
153
+ // Masked credentials: on Linux; on macOS not yet (anthropics/sandbox-runtime#593).
154
+ masks: (platform) => platform === "linux" ? null
155
+ : `masked credentials (key_mode = "inject") on ${platform}: sandbox-runtime does not mask them there yet (anthropics/sandbox-runtime#593); tartaros does`,
153
156
  // `--` before the command, and it is not cosmetic. Without it the runtime's
154
157
  // own argument parser reaches into what the agent was invoked with and eats
155
158
  // anything resembling one of its flags: `claude --settings`, `claude -c`.
@@ -95,6 +95,9 @@ export const tartaros = {
95
95
  locate: () => resolveTartaros(),
96
96
  missing: () => `tartaros not found: neither ${TARTAROS_PACKAGE}, which installs with seisin unless optional dependencies are left out, nor tartaros on PATH.`,
97
97
  asksFile: true,
98
+ // Masked credentials: tartaros says whether it masks them, through explain
99
+ // (0.1.0-alpha.16 and later do; earlier ones refuse the field).
100
+ masks: () => null,
98
101
  adapt: tartarosSettings,
99
102
  machRegister: true,
100
103
  assess: (_settings, { bin, file, platform } = {}) => tartarosAssess(bin, file, { platform }),
package/src/serve.js CHANGED
@@ -23,6 +23,7 @@ import { pending, requestsPath, settle, applyGrant, refuseIfBarred, editPolicy,
23
23
  import { causesOf, describeWalls, parseSince, queue, verdicts, wallsByRole } from "./views.js";
24
24
  import { planEdit, hashOf } from "./controls.js";
25
25
  import { inspect } from "./inspect.js";
26
+ import { scanRuns, runsView, runOf, runPolicy, filtersOf, isRunId } from "./runs.js";
26
27
 
27
28
  // Moved to views.js, which the MCP server shares; kept importable from here.
28
29
  export { causesOf } from "./views.js";
@@ -104,7 +105,7 @@ function remember(map, key, value, max = 4) {
104
105
  * page downloaded every two seconds and it drew none of them. One role's are
105
106
  * served on demand by /api/settings.
106
107
  */
107
- export function state(configPath, { since = null, snap = snapshot(configPath) } = {}) {
108
+ export function state(configPath, { since = null, snap = snapshot(configPath), readOnly = false } = {}) {
108
109
  const { cfg, text, all } = snap;
109
110
 
110
111
  // The date filter narrows everything read from the log: activity, causes,
@@ -162,7 +163,9 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
162
163
  // A write keyed by a folder can be granted as its one file instead; the
163
164
  // line that choice carries is said here, once, so the page and the CLI
164
165
  // say the same thing about Claude Code.
165
- requests: queue(cfg, all).map((r) => ({
166
+ // Read only (an archived run): a queue an older seisin left is an error to
167
+ // show, never a rewrite of a file that is not this console's (requests.js).
168
+ requests: queue(cfg, all, { readOnly }).map((r) => ({
166
169
  ...r,
167
170
  // Said by the server in the same words as the CLI, so the page never
168
171
  // works out where a request goes on its own (handoff.js).
@@ -197,14 +200,14 @@ export function state(configPath, { since = null, snap = snapshot(configPath) }
197
200
  * moved — and a tag naming it, so a poll that already has it gets a 304 and
198
201
  * no body at all.
199
202
  */
200
- function stateBody(configPath, since) {
203
+ function stateBody(configPath, since, { readOnly = false } = {}) {
201
204
  // One snapshot for the tag and the body. Each used to take its own, so a
202
205
  // file that moved between the two calls got a body filed under the tag of
203
206
  // what was there before — and a 304 for it until something else moved.
204
207
  const snap = snapshot(configPath);
205
- const k = `${snap.key}|${since}`;
208
+ const k = `${snap.key}|${since}|${readOnly}`;
206
209
  return memo.bodies.get(k) ?? remember(memo.bodies, k, {
207
- body: JSON.stringify(state(configPath, { since, snap })),
210
+ body: JSON.stringify(state(configPath, { since, snap, readOnly })),
208
211
  etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
209
212
  });
210
213
  }
@@ -458,7 +461,37 @@ function declineAll(configPath, { keys, reason }) {
458
461
  return { ok: true, declined };
459
462
  }
460
463
 
461
- export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
464
+ /**
465
+ * The console over a directory of runs (`seisin ui --runs`, runs.js), read
466
+ * only: the territory each run had was lent by whoever launched it, and a
467
+ * grant written into an archived policy changes nothing. So in this mode
468
+ * every route that writes answers 405, and the page draws no grant.
469
+ */
470
+ function runsBody(dir, query) {
471
+ const filters = filtersOf(query);
472
+ const scanned = scanRuns(dir);
473
+ const k = `${JSON.stringify(filters)}|${scanned.sig}`;
474
+ return runsMemo.get(k) ?? remember(runsMemo, k, {
475
+ body: JSON.stringify(runsView(dir, filters, { scanned })),
476
+ etag: `"${createHash("sha256").update(k).digest("hex").slice(0, 24)}"`,
477
+ });
478
+ }
479
+ const runsMemo = new Map();
480
+
481
+ /** One run as the page draws it: its own state, read only, with what run.json said. */
482
+ function runBody(dir, id, since) {
483
+ const run = runOf(dir, id);
484
+ if (run.outcome === "unread") throw Object.assign(new Error(`run ${id}: ${run.error}`), { status: 422 });
485
+ const got = stateBody(runPolicy(dir, id), since, { readOnly: true });
486
+ // The state is already a string; the run is stitched onto it rather than
487
+ // parsed and serialized again on every poll.
488
+ return {
489
+ body: `${got.body.slice(0, -1)},"mode":"runs","readOnly":true,"run":${JSON.stringify(run)}}`,
490
+ etag: `"${createHash("sha256").update(`${got.etag}|${JSON.stringify(run)}`).digest("hex").slice(0, 24)}"`,
491
+ };
492
+ }
493
+
494
+ export function serve(configPath, port = 4178, { lockWaitMs = 10000, runsDir = null } = {}) {
462
495
  const page = join(HERE, "..", "ui", "index.html");
463
496
  if (!existsSync(page)) throw new Error("the console is missing from this install");
464
497
 
@@ -553,6 +586,12 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
553
586
  return answer(statusOf(e), { error: e.message });
554
587
  }
555
588
  };
589
+ const query = (req.url ?? "").split("?")[1] ?? "";
590
+ // Over runs, nothing is written: not a decision, not a control file.
591
+ if (runsDir && req.method !== "GET" && req.method !== "HEAD") {
592
+ res.writeHead(405, { allow: "GET", "content-type": "application/json" });
593
+ return res.end(JSON.stringify({ error: "read only: this console shows archived runs. Access is asked for where the run was launched, not here." }));
594
+ }
556
595
  // ~85 bytes per key: 1 MB is ten thousand requests on one screen.
557
596
  if (req.method === "POST" && pathOf(req.url) === "/api/decline-all")
558
597
  return post(1024 * 1024, (args) => declineAll(configPath, args));
@@ -571,12 +610,23 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
571
610
  res.writeHead(405, { allow: "GET" });
572
611
  return res.end();
573
612
  }
574
- if (pathOf(req.url) === "/api/state") {
613
+ if (pathOf(req.url) === "/api/info")
614
+ return answer(200, runsDir ? { mode: "runs", dir: runsDir, readOnly: true } : { mode: "policy", config: configPath, readOnly: false });
615
+ const routes = runsDir
616
+ ? { "/api/runs": () => runsBody(runsDir, query),
617
+ "/api/run": () => {
618
+ const id = new URLSearchParams(query).get("run");
619
+ if (!isRunId(id)) throw bad("run must name one of the runs: ?run=<run_id>");
620
+ return runBody(runsDir, id, sinceOf(req.url));
621
+ } }
622
+ : { "/api/state": () => stateBody(configPath, sinceOf(req.url)) };
623
+ const route = routes[pathOf(req.url)];
624
+ if (route) {
575
625
  let got;
576
626
  try {
577
- got = stateBody(configPath, sinceOf(req.url));
627
+ got = route();
578
628
  } catch (e) {
579
- return answer(500, { error: e.message });
629
+ return answer(e.status ?? 500, { error: e.message });
580
630
  }
581
631
  // The page polls, so tell every cache in between to stay out of it;
582
632
  // the page itself sends the tag back and is told when nothing moved.
@@ -587,8 +637,16 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
587
637
  }
588
638
  if (pathOf(req.url) === "/api/settings") {
589
639
  try {
590
- const role = new URLSearchParams(req.url.split("?")[1] ?? "").get("role");
591
- return answer(200, { role, settings: settingsOf(configPath, role) });
640
+ const params = new URLSearchParams(query);
641
+ const role = params.get("role");
642
+ let policy = configPath;
643
+ if (runsDir) {
644
+ const id = params.get("run");
645
+ if (!isRunId(id)) throw bad("run must name one of the runs: ?run=<run_id>");
646
+ runOf(runsDir, id);
647
+ policy = runPolicy(runsDir, id);
648
+ }
649
+ return answer(200, { role, settings: settingsOf(policy, role) });
592
650
  } catch (e) {
593
651
  return answer(e.status ?? 500, { error: e.message });
594
652
  }
@@ -611,6 +669,9 @@ export function serve(configPath, port = 4178, { lockWaitMs = 10000 } = {}) {
611
669
  // For `seisin ui`, which builds the URL, and for tests. Never sent.
612
670
  Object.defineProperty(server, "seisinToken", { value: token });
613
671
  ok(server);
672
+ // The first read of a few hundred runs takes seconds; started now, it is
673
+ // mostly done by the time the page asks.
674
+ if (runsDir) setImmediate(() => { try { scanRuns(runsDir); } catch {} });
614
675
  });
615
676
  server.on("error", (e) =>
616
677
  fail(e.code === "EADDRINUSE"
package/src/uilink.js CHANGED
@@ -35,10 +35,12 @@ function linkFile(port, base) {
35
35
  * console another repo had started — and opened a page about the wrong policy
36
36
  * with nothing on it to say so. The policy's path travels with the link now.
37
37
  */
38
- export function writeUiLink(port, url, base, policy = null) {
38
+ export function writeUiLink(port, url, base, policy = null, { mode = "policy", dir = null } = {}) {
39
39
  const file = linkFile(port, base);
40
40
  const temp = `${file}.tmp-${randomBytes(12).toString("hex")}`;
41
- writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy }) + "\n", { flag: "wx", mode: 0o600 });
41
+ // `mode` and `dir`: a console over a directory of runs (`--runs`) serves no
42
+ // one policy, and whoever reads this record has to be able to tell.
43
+ writeFileSync(temp, JSON.stringify({ url, pid: process.pid, policy, mode, dir }) + "\n", { flag: "wx", mode: 0o600 });
42
44
  try {
43
45
  // Replacing an existing record must neither inherit its mode nor follow
44
46
  // its symlink. Readers see one complete record, including after a crash.
@@ -61,7 +63,12 @@ export function readUiLink(port, base) {
61
63
  if (!e || typeof e.url !== "string") return null;
62
64
  let alive = null;
63
65
  if (Number.isInteger(e.pid) && e.pid > 0) alive = processAlive(e.pid);
64
- return { url: e.url, pid: e.pid ?? null, alive, policy: typeof e.policy === "string" ? e.policy : null };
66
+ return {
67
+ url: e.url, pid: e.pid ?? null, alive,
68
+ policy: typeof e.policy === "string" ? e.policy : null,
69
+ mode: e.mode === "runs" ? "runs" : "policy",
70
+ dir: typeof e.dir === "string" ? e.dir : null,
71
+ };
65
72
  }
66
73
 
67
74
  /** Removes the record. Called on exit, and when a stale one is found. */