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.
- package/README.md +5 -4
- package/package.json +2 -2
- package/src/canary.js +10 -0
- package/src/cli.js +2 -1
- package/src/commands/run.js +8 -0
- package/src/commands/ui.js +46 -10
- package/src/help.js +6 -4
- package/src/inspect/inject.js +16 -5
- package/src/keys.js +15 -16
- package/src/runs.js +298 -0
- package/src/sandboxes/command.js +3 -0
- package/src/sandboxes/index.js +5 -1
- package/src/sandboxes/srt.js +3 -0
- package/src/sandboxes/tartaros.js +3 -0
- package/src/serve.js +72 -11
- package/src/uilink.js +10 -3
- package/ui/index.html +289 -18
package/src/sandboxes/srt.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/
|
|
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 =
|
|
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
|
|
591
|
-
|
|
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
|
-
|
|
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 {
|
|
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. */
|