@crouton-kit/crouter 0.3.67 → 0.3.70
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/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
- package/dist/clients/attach/attach-cmd.js +668 -668
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/pickers.js +2 -0
- package/dist/clients/attach/slash-commands.js +1 -0
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -0
- package/dist/commands/node.js +3 -3
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/sys/setup-core.js +2 -0
- package/dist/commands/sys/setup.js +132 -16
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/revive.test.js +70 -1
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/broker.js +2 -0
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/revive.js +187 -140
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/transport-local.js +12 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.js +10 -10
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/assets/{index-BvzxXXGU.js → index-DiFuLcp6.js} +19 -18
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +10 -7
- package/scripts/postinstall.mjs +1 -1
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -45,12 +45,28 @@ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
|
45
45
|
// A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
|
|
46
46
|
// seam. Absolute paths so it works regardless of the spawned window's cwd.
|
|
47
47
|
const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
|
|
48
|
+
// Every raw `tmux` call the harness/full-tier tests make against the shared
|
|
49
|
+
// DEFAULT server is synchronous (spawnSync). Node's spawnSync blocks the WHOLE
|
|
50
|
+
// worker (no event loop, no `node --test` timeout can preempt it) until the
|
|
51
|
+
// child exits — with no `timeout` option it can wait FOREVER. A wedged/CPU-
|
|
52
|
+
// starved tmux server (the acknowledged "intermittent Linux-only crash-revive
|
|
53
|
+
// wedge", CI-only) has been observed to hang one of these calls indefinitely,
|
|
54
|
+
// which permanently consumes one of the `--test-concurrency` slots; as more
|
|
55
|
+
// full-tier files each hit the SAME wedged server, every slot eventually gets
|
|
56
|
+
// consumed the same way and the whole suite flatlines with zero further TAP
|
|
57
|
+
// output (confirmed 3/3 in CI, see ci-full-suite-hang-blocker.md). TMUX_TIMEOUT_MS
|
|
58
|
+
// bounds every raw tmux spawnSync call so a wedge fails that ONE call (and the
|
|
59
|
+
// test it belongs to) fast instead of hanging the run.
|
|
60
|
+
export const TMUX_TIMEOUT_MS = 10_000;
|
|
48
61
|
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
49
62
|
export function hasTmux() {
|
|
50
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
63
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }).status === 0;
|
|
51
64
|
}
|
|
52
65
|
function tmuxSessionExists(session) {
|
|
53
|
-
return spawnSync('tmux', ['has-session', '-t', session], {
|
|
66
|
+
return (spawnSync('tmux', ['has-session', '-t', session], {
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
69
|
+
}).status === 0);
|
|
54
70
|
}
|
|
55
71
|
// Every canvas/tmux var the harness itself runs under — scrubbed from each child
|
|
56
72
|
// env so a spawned CLI cannot leak the REAL canvas into the isolated test.
|
|
@@ -172,14 +188,19 @@ export async function createHarness(opts = {}) {
|
|
|
172
188
|
if (!headless) {
|
|
173
189
|
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
|
|
174
190
|
stdio: 'ignore',
|
|
191
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
175
192
|
});
|
|
176
193
|
// Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
|
|
177
194
|
// in this session inherits it — so any pi the runtime launches (boot, revive)
|
|
178
195
|
// resolves to the fake-pi vehicle, not a real pi.
|
|
179
196
|
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
|
|
180
197
|
stdio: 'ignore',
|
|
198
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
199
|
+
});
|
|
200
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], {
|
|
201
|
+
stdio: 'ignore',
|
|
202
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
181
203
|
});
|
|
182
|
-
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
|
|
183
204
|
}
|
|
184
205
|
const pidsToKill = new Set();
|
|
185
206
|
let nextRootSeq = 0;
|
|
@@ -517,7 +538,10 @@ export async function createHarness(opts = {}) {
|
|
|
517
538
|
return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
518
539
|
},
|
|
519
540
|
async dispose() {
|
|
520
|
-
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
541
|
+
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
542
|
+
stdio: 'ignore',
|
|
543
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
544
|
+
});
|
|
521
545
|
// Best-effort: a superviseTick-triggered revive of a fabricated node fires
|
|
522
546
|
// a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
|
|
523
547
|
// below makes a mid-boot one self-terminate when its files vanish). Kill
|
|
@@ -47,6 +47,7 @@ import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
|
|
|
47
47
|
function persona(m) {
|
|
48
48
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
49
49
|
}
|
|
50
|
+
const FULL_CLI_TEST_TIMEOUT_MS = 180_000;
|
|
50
51
|
// ===========================================================================
|
|
51
52
|
// (b) THE demote / recycle SPLIT — two DISTINCT verbs:
|
|
52
53
|
// • `node lifecycle demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
@@ -60,9 +61,9 @@ function persona(m) {
|
|
|
60
61
|
// ===========================================================================
|
|
61
62
|
test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle recycle is FINISH+RECYCLE',
|
|
62
63
|
// Drives several full-CLI subprocess spawns (each a cold tsx compile of the
|
|
63
|
-
// whole CLI) + recycleNode;
|
|
64
|
-
//
|
|
65
|
-
{ timeout:
|
|
64
|
+
// whole CLI) + recycleNode; this is a subprocess-heavy regression and gets a
|
|
65
|
+
// CI-safe per-test budget while the npm script isolates it at file concurrency 1.
|
|
66
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
66
67
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
67
68
|
try {
|
|
68
69
|
const A = h.spawnRoot('resident root');
|
|
@@ -136,7 +137,7 @@ test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle
|
|
|
136
137
|
// ===========================================================================
|
|
137
138
|
test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)',
|
|
138
139
|
// Full-CLI subprocess spawn under load — see the sibling test's budget note.
|
|
139
|
-
{ timeout:
|
|
140
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
140
141
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
141
142
|
try {
|
|
142
143
|
const A = h.spawnRoot('resident root');
|
|
@@ -27,7 +27,7 @@ test('review still permits equal-or-stronger explicit models', () => {
|
|
|
27
27
|
assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
|
|
28
28
|
});
|
|
29
29
|
test('explore remains cheap by default and may explicitly run light', () => {
|
|
30
|
-
assert.equal(spec('explore'), normalizeModel('
|
|
30
|
+
assert.equal(spec('explore'), normalizeModel('openai/light'));
|
|
31
31
|
assert.equal(spec('explore', 'light'), normalizeModel('light'));
|
|
32
32
|
});
|
|
33
33
|
// `node new --model` widened to the full spec grammar (provider/tier, exact
|
|
@@ -34,9 +34,11 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
|
34
34
|
import { tmpdir } from 'node:os';
|
|
35
35
|
import { join } from 'node:path';
|
|
36
36
|
import { spawn, spawnSync } from 'node:child_process';
|
|
37
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
37
38
|
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
38
39
|
import { readInboxSince } from '../feed/inbox.js';
|
|
39
|
-
import { closeDb } from '../canvas/db.js';
|
|
40
|
+
import { closeDb, openDb } from '../canvas/db.js';
|
|
41
|
+
import { canvasDbPath } from '../canvas/paths.js';
|
|
40
42
|
import { buildPiArgv } from '../runtime/launch.js';
|
|
41
43
|
import { headlessBrokerHost } from '../runtime/host.js';
|
|
42
44
|
import { resumeArgs, reviveNode } from '../runtime/revive.js';
|
|
@@ -350,3 +352,70 @@ test('reviveNode fans a doctrine wake to subscribers when launch/preflight throw
|
|
|
350
352
|
await h.dispose();
|
|
351
353
|
}
|
|
352
354
|
});
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Duplicate-broker leak (live-incident regression): reviveNode's guard-check-
|
|
357
|
+
// then-launch-then-recordPid critical section must be a single atomic write
|
|
358
|
+
// transaction, so a concurrent reviveNode call for the SAME node (a second OS
|
|
359
|
+
// process racing this one against the same canvas.db, e.g. a retried web-relay
|
|
360
|
+
// revive under socket thrash) cannot read the same stale pid, launch its own
|
|
361
|
+
// broker, and overwrite this call's freshly-recorded pid — leaking this call's
|
|
362
|
+
// broker as an untracked live process. Proven here by holding a competing raw
|
|
363
|
+
// write transaction open on a SECOND connection to the same db file before
|
|
364
|
+
// calling reviveNode: with the fix, reviveNode's own BEGIN IMMEDIATE cannot
|
|
365
|
+
// acquire the write lock while the other connection holds it, so it fails
|
|
366
|
+
// closed (throws) rather than silently interleaving with the held lock and
|
|
367
|
+
// corrupting the guard's read-then-write. Releasing the competing lock lets an
|
|
368
|
+
// identical reviveNode call proceed and record a fresh pid normally — the
|
|
369
|
+
// serialization is transient contention, not a permanent wedge.
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
test('reviveNode\'s guard-check-through-recordPid section is one atomic write transaction (duplicate-broker-leak regression)', async () => {
|
|
372
|
+
const h = await createHarness({ headless: true, sessionPrefix: 'crtr-revive-lock' });
|
|
373
|
+
let contender;
|
|
374
|
+
try {
|
|
375
|
+
const sessionFile = join(home, 'sessions', 'crtr-revive-lock.jsonl');
|
|
376
|
+
mkdirSync(join(home, 'sessions'), { recursive: true });
|
|
377
|
+
writeFileSync(sessionFile, '{}\n', 'utf8');
|
|
378
|
+
const stalePid = deadPid();
|
|
379
|
+
const M = h.fabricateBrokerNode({
|
|
380
|
+
status: 'active',
|
|
381
|
+
intent: null,
|
|
382
|
+
pi_pid: stalePid,
|
|
383
|
+
pi_session_id: 'uuid-lock',
|
|
384
|
+
pi_session_file: sessionFile,
|
|
385
|
+
});
|
|
386
|
+
// Shorten THIS test's connection's busy_timeout (default 5000ms, db.ts) so
|
|
387
|
+
// the contended call below fails fast instead of waiting out the real
|
|
388
|
+
// production window — it's the ACQUIRING side's timeout that governs how
|
|
389
|
+
// long `BEGIN IMMEDIATE` waits, not the holder's.
|
|
390
|
+
openDb().exec('PRAGMA busy_timeout = 200;');
|
|
391
|
+
// A second raw connection to the SAME db file holds an exclusive write
|
|
392
|
+
// transaction open — modeling a concurrent reviveNode call (a different OS
|
|
393
|
+
// process, in the real incident) that already won the race and is mid-launch.
|
|
394
|
+
contender = new DatabaseSync(canvasDbPath());
|
|
395
|
+
contender.exec('BEGIN IMMEDIATE');
|
|
396
|
+
assert.throws(() => reviveNode(M, { resume: true }), /locked|busy/i, 'a reviveNode call cannot acquire the write lock while a concurrent revive holds it — no interleaved double-launch');
|
|
397
|
+
assert.equal(h.node(M).pi_pid, stalePid, 'blocked-out call recorded nothing — the held pid is untouched');
|
|
398
|
+
// Release the contender's lock — an identical reviveNode call now proceeds
|
|
399
|
+
// normally, exactly like the uncontended case.
|
|
400
|
+
contender.exec('COMMIT');
|
|
401
|
+
contender.close();
|
|
402
|
+
contender = undefined;
|
|
403
|
+
reviveNode(M, { resume: true });
|
|
404
|
+
const after = h.node(M);
|
|
405
|
+
assert.notEqual(after.pi_pid, stalePid, 'once uncontended, revive proceeds and records a fresh pid');
|
|
406
|
+
assert.equal(after.cycles, 1, 'the uncontended revive bumped the cycle counter');
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
if (contender !== undefined) {
|
|
410
|
+
try {
|
|
411
|
+
contender.exec('ROLLBACK');
|
|
412
|
+
}
|
|
413
|
+
catch { /* best-effort */ }
|
|
414
|
+
try {
|
|
415
|
+
contender.close();
|
|
416
|
+
}
|
|
417
|
+
catch { /* best-effort */ }
|
|
418
|
+
}
|
|
419
|
+
await h.dispose();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
@@ -45,7 +45,7 @@ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
|
|
|
45
45
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
|
|
48
|
+
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async (t) => {
|
|
49
49
|
const server = await startSseServer((res) => {
|
|
50
50
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
51
51
|
res.write(': connected\n\n');
|
|
@@ -55,15 +55,18 @@ test('a listener fires once per known-kind SSE event, and never for a ping or an
|
|
|
55
55
|
res.end();
|
|
56
56
|
});
|
|
57
57
|
const stream = new RemoteEventStream(server.url, 'tok');
|
|
58
|
+
// Register cleanup BEFORE any assertion runs — a thrown assertion must not
|
|
59
|
+
// leave the client's reconnect loop or the server's listening socket alive
|
|
60
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
61
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
58
62
|
const seen = [];
|
|
59
63
|
stream.subscribe((kind) => seen.push(kind));
|
|
60
64
|
stream.start();
|
|
61
65
|
await waitFor(() => seen.length > 0);
|
|
62
66
|
stream.stop();
|
|
63
67
|
assert.deepEqual(seen, ['nodes']);
|
|
64
|
-
await server.close();
|
|
65
68
|
});
|
|
66
|
-
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
|
|
69
|
+
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async (t) => {
|
|
67
70
|
const server = await startSseServer((res) => {
|
|
68
71
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
69
72
|
res.end();
|
|
@@ -73,6 +76,7 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
73
76
|
// no header, including Host, is permitted to carry it.
|
|
74
77
|
const token = 'secret-sse-token-value';
|
|
75
78
|
const stream = new RemoteEventStream(server.url, token);
|
|
79
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
76
80
|
stream.start();
|
|
77
81
|
await waitFor(() => server.requests.length > 0);
|
|
78
82
|
stream.stop();
|
|
@@ -87,9 +91,8 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
87
91
|
assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
|
|
88
92
|
}
|
|
89
93
|
assert.ok(!req.url.includes(token));
|
|
90
|
-
await server.close();
|
|
91
94
|
});
|
|
92
|
-
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
|
|
95
|
+
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async (t) => {
|
|
93
96
|
const server = await startSseServer((res) => {
|
|
94
97
|
res.writeHead(500);
|
|
95
98
|
res.end();
|
|
@@ -101,6 +104,7 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
101
104
|
// wide enough that ordinary event-loop/scheduler jitter can't flip it.
|
|
102
105
|
const BACKOFF = 200;
|
|
103
106
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
107
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
104
108
|
stream.start();
|
|
105
109
|
// t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
|
|
106
110
|
await waitFor(() => server.hitCount() >= 1);
|
|
@@ -119,10 +123,8 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
119
123
|
// this window; a cleared one (fixed) leaves it at 2.
|
|
120
124
|
await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
|
|
121
125
|
assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
|
|
122
|
-
stream.stop();
|
|
123
|
-
await server.close();
|
|
124
126
|
});
|
|
125
|
-
test('stop() halts the reconnect loop — no further requests after it is called', async () => {
|
|
127
|
+
test('stop() halts the reconnect loop — no further requests after it is called', async (t) => {
|
|
126
128
|
const server = await startSseServer((res) => {
|
|
127
129
|
// Every connection is refused immediately (no SSE headers, just a close)
|
|
128
130
|
// so the client's loop falls into its backoff-and-retry path.
|
|
@@ -131,6 +133,7 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
131
133
|
});
|
|
132
134
|
const BACKOFF = 20;
|
|
133
135
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
136
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
134
137
|
stream.start();
|
|
135
138
|
await waitFor(() => server.hitCount() >= 1);
|
|
136
139
|
const hitsAtStop = server.hitCount();
|
|
@@ -140,5 +143,4 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
140
143
|
// continuing to retry in the background after stop() returned.
|
|
141
144
|
await new Promise((r) => setTimeout(r, BACKOFF * 5));
|
|
142
145
|
assert.equal(server.hitCount(), hitsAtStop);
|
|
143
|
-
await server.close();
|
|
144
146
|
});
|
|
@@ -140,7 +140,7 @@ function seedPoisonedLocalState() {
|
|
|
140
140
|
].join('\n'));
|
|
141
141
|
return sf;
|
|
142
142
|
}
|
|
143
|
-
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
143
|
+
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
144
144
|
sessionFile = seedPoisonedLocalState();
|
|
145
145
|
const server = await startMockCrtrServer((args) => {
|
|
146
146
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -164,6 +164,10 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
164
164
|
}
|
|
165
165
|
return { status: 500, body: {} };
|
|
166
166
|
});
|
|
167
|
+
// Register the close BEFORE any assertion runs — a listening server left
|
|
168
|
+
// open by a thrown assertion keeps this test file's process alive forever
|
|
169
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
170
|
+
t.after(() => server.close());
|
|
167
171
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
168
172
|
const rows = await dashboardRowsAllFromSource(source);
|
|
169
173
|
assert.equal(rows.length, 1);
|
|
@@ -180,7 +184,6 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
180
184
|
assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
|
|
181
185
|
assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
|
|
182
186
|
assert.equal(previewed.previewLoaded, true);
|
|
183
|
-
await server.close();
|
|
184
187
|
});
|
|
185
188
|
// The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
|
|
186
189
|
// branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
|
|
@@ -190,7 +193,7 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
190
193
|
// test above never exercises it, so a regression that reintroduced local
|
|
191
194
|
// telemetry/ask/fault reads into the static forest renderer specifically would
|
|
192
195
|
// pass that test while still leaking poisoned local state here.
|
|
193
|
-
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
196
|
+
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
194
197
|
const sf = seedPoisonedLocalState();
|
|
195
198
|
const server = await startMockCrtrServer((args) => {
|
|
196
199
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -211,11 +214,11 @@ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) f
|
|
|
211
214
|
}
|
|
212
215
|
return { status: 500, body: {} };
|
|
213
216
|
});
|
|
217
|
+
t.after(() => server.close());
|
|
214
218
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
215
219
|
const out = await renderForestFromSource(source);
|
|
216
220
|
assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
|
|
217
221
|
assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
|
|
218
222
|
assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
|
|
219
223
|
assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
|
|
220
|
-
await server.close();
|
|
221
224
|
});
|
|
@@ -71,19 +71,36 @@ test('a burst of SSE-style triggers produces ONE round of remote requests, not N
|
|
|
71
71
|
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
// The server MUST close in a `finally` — a listening TCP server keeps its
|
|
75
|
+
// process's event loop non-empty forever. Under `node --test`, every test
|
|
76
|
+
// file is its own child process; if an assertion in this test throws before
|
|
77
|
+
// the old inline `server.close()` at the end of the function ran, the
|
|
78
|
+
// server (and the process) never exited, wedging one `--test-concurrency`
|
|
79
|
+
// slot permanently and hanging the whole suite (see the CI-hang postmortem).
|
|
80
|
+
try {
|
|
81
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
82
|
+
const addr = server.address();
|
|
83
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
84
|
+
const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
|
|
85
|
+
const rebuilder = createCoalescedRebuilder(async () => {
|
|
86
|
+
source.invalidate();
|
|
87
|
+
await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
|
|
88
|
+
}, 20);
|
|
89
|
+
for (let i = 0; i < 6; i++)
|
|
90
|
+
rebuilder.trigger();
|
|
91
|
+
// Poll for the request to land instead of sleeping a fixed duration —
|
|
92
|
+
// deterministic under CI's variable CPU load, and doesn't slow down the
|
|
93
|
+
// common case (returns as soon as the debounce + request round trip land).
|
|
94
|
+
const deadline = Date.now() + 2000;
|
|
95
|
+
while (hits < 1 && Date.now() < deadline) {
|
|
96
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
97
|
+
}
|
|
98
|
+
assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
server.closeAllConnections();
|
|
102
|
+
await new Promise((r) => server.close(() => r()));
|
|
103
|
+
}
|
|
87
104
|
});
|
|
88
105
|
// Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
|
|
89
106
|
// helper in isolation. Every test above instantiates `createCoalescedRebuilder`
|
|
@@ -74,16 +74,20 @@ export declare function identitiesMatch(a: string, b: string): boolean;
|
|
|
74
74
|
* `identitiesMatch` says so, else `'dead'` (positively reused). */
|
|
75
75
|
export type RecordedPidLiveness = 'alive' | 'dead' | 'indeterminate';
|
|
76
76
|
export declare function recordedPidLiveness(pid: number | null | undefined, expectedIdentity: string | null | undefined): RecordedPidLiveness;
|
|
77
|
-
/** Boolean
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
77
|
+
/** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
|
|
78
|
+
* `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
|
|
79
|
+
* either way) reads true alongside `alive` — never act on a maybe-ours pid
|
|
80
|
+
* on mere uncertainty, matching this module's universal discipline (a
|
|
81
|
+
* POSITIVE verdict is the only thing that changes behavior, never an
|
|
82
|
+
* absence of evidence). This is the correct read for BOTH signalling
|
|
83
|
+
* callers (never signal on uncertainty) and the double-launch guard in
|
|
84
|
+
* `reviveNode` (never RELAUNCH on uncertainty — the identity probe is a
|
|
85
|
+
* fork+exec that fails precisely under the OOM pressure where an extra
|
|
86
|
+
* broker is most destructive, while a truly dead pid always reads `dead`
|
|
87
|
+
* via the fork-free signal-0 path; see the guard's comment for the live
|
|
88
|
+
* incident this closed). Callers that need "confirmed alive" three-valued
|
|
89
|
+
* precision (e.g. the daemon's grace-clock bookkeeping) call
|
|
90
|
+
* `recordedPidLiveness` directly. */
|
|
87
91
|
export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
|
|
88
92
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
89
93
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -132,16 +132,20 @@ export function recordedPidLiveness(pid, expectedIdentity) {
|
|
|
132
132
|
logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
|
|
133
133
|
return matches ? 'alive' : 'dead';
|
|
134
134
|
}
|
|
135
|
-
/** Boolean
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
135
|
+
/** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
|
|
136
|
+
* `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
|
|
137
|
+
* either way) reads true alongside `alive` — never act on a maybe-ours pid
|
|
138
|
+
* on mere uncertainty, matching this module's universal discipline (a
|
|
139
|
+
* POSITIVE verdict is the only thing that changes behavior, never an
|
|
140
|
+
* absence of evidence). This is the correct read for BOTH signalling
|
|
141
|
+
* callers (never signal on uncertainty) and the double-launch guard in
|
|
142
|
+
* `reviveNode` (never RELAUNCH on uncertainty — the identity probe is a
|
|
143
|
+
* fork+exec that fails precisely under the OOM pressure where an extra
|
|
144
|
+
* broker is most destructive, while a truly dead pid always reads `dead`
|
|
145
|
+
* via the fork-free signal-0 path; see the guard's comment for the live
|
|
146
|
+
* incident this closed). Callers that need "confirmed alive" three-valued
|
|
147
|
+
* precision (e.g. the daemon's grace-clock bookkeeping) call
|
|
148
|
+
* `recordedPidLiveness` directly. */
|
|
145
149
|
export function isRecordedPidAlive(pid, expectedIdentity) {
|
|
146
150
|
return recordedPidLiveness(pid, expectedIdentity) !== 'dead';
|
|
147
151
|
}
|
package/dist/core/command.d.ts
CHANGED
|
@@ -56,6 +56,17 @@ export interface BranchDef {
|
|
|
56
56
|
rootEntry?: RootEntry;
|
|
57
57
|
/** Opt into editor slash-command exposure (see SlashSpec). */
|
|
58
58
|
slash?: SlashSpec;
|
|
59
|
+
/** Opt this branch out of the tree model entirely: every token after this
|
|
60
|
+
* branch's name is forwarded VERBATIM (raw argv, not the `--json`-filtered
|
|
61
|
+
* tokens) to an external binary via spawn, with stdio inherited and the
|
|
62
|
+
* child's exit code propagated. A deliberate, documented exception for
|
|
63
|
+
* wrapping an external CLI whose own schema crtr cannot and must not
|
|
64
|
+
* duplicate (see `crtr capture`) — not a general escape hatch. A
|
|
65
|
+
* passthrough branch should declare no children. */
|
|
66
|
+
passthrough?: {
|
|
67
|
+
bin: string;
|
|
68
|
+
installHint: string;
|
|
69
|
+
};
|
|
59
70
|
children: (LeafDef | BranchDef)[];
|
|
60
71
|
}
|
|
61
72
|
export interface RootDef {
|
|
@@ -81,6 +92,10 @@ export declare function defineBranch(opts: {
|
|
|
81
92
|
help: BranchHelp;
|
|
82
93
|
rootEntry?: RootEntry;
|
|
83
94
|
slash?: SlashSpec;
|
|
95
|
+
passthrough?: {
|
|
96
|
+
bin: string;
|
|
97
|
+
installHint: string;
|
|
98
|
+
};
|
|
84
99
|
children: (LeafDef | BranchDef)[];
|
|
85
100
|
}): BranchDef;
|
|
86
101
|
/** Walk the whole tree and collect every node's SlashSpec (depth-first). Used
|
package/dist/core/command.js
CHANGED
|
@@ -10,6 +10,8 @@ import { renderResult } from './render.js';
|
|
|
10
10
|
import { CrtrError } from './errors.js';
|
|
11
11
|
import { ExitCode } from '../types.js';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { constants as osConstants } from 'node:os';
|
|
13
15
|
// ---------------------------------------------------------------------------
|
|
14
16
|
// Factory functions
|
|
15
17
|
// ---------------------------------------------------------------------------
|
|
@@ -34,6 +36,14 @@ function visibleSubCount(def) {
|
|
|
34
36
|
return (def.help.listing ?? []).filter((c) => c.tier !== 'hidden').length;
|
|
35
37
|
}
|
|
36
38
|
export function defineBranch(opts) {
|
|
39
|
+
// A passthrough branch must be childless: walk() only stops at the deepest
|
|
40
|
+
// matched node, so a passthrough branch with children would let some
|
|
41
|
+
// invocations descend into a child (tree dispatch) while others hit the
|
|
42
|
+
// passthrough interception — silently inconsistent transparency. Fail loudly
|
|
43
|
+
// at construction instead of letting that split happen at runtime.
|
|
44
|
+
if (opts.passthrough !== undefined && opts.children.length > 0) {
|
|
45
|
+
throw new Error(`defineBranch("${opts.name}"): a passthrough branch must declare no children (found ${opts.children.length})`);
|
|
46
|
+
}
|
|
37
47
|
// Assemble the parent-level listing straight from the child defs — each node
|
|
38
48
|
// owns its own description/whenToUse/tier, so the parent copies nothing
|
|
39
49
|
// (principle 16). Bottom-up construction guarantees a child branch's listing
|
|
@@ -57,6 +67,7 @@ export function defineBranch(opts) {
|
|
|
57
67
|
help: opts.help,
|
|
58
68
|
rootEntry: opts.rootEntry,
|
|
59
69
|
slash: opts.slash,
|
|
70
|
+
passthrough: opts.passthrough,
|
|
60
71
|
children: opts.children,
|
|
61
72
|
};
|
|
62
73
|
}
|
|
@@ -169,6 +180,59 @@ function renderNode(node) {
|
|
|
169
180
|
function helpRequested(remaining) {
|
|
170
181
|
return remaining.some((t) => t === '-h' || t === '--help');
|
|
171
182
|
}
|
|
183
|
+
/** Recover the RAW argv slice after a matched passthrough branch's path,
|
|
184
|
+
* unaffected by the `--json`-stripping done to build `tokens`. `path` is a
|
|
185
|
+
* prefix of non-`--json` tokens in order, so walking `rawTokens` while
|
|
186
|
+
* skipping `--json` occurrences until `path` is fully matched lands the
|
|
187
|
+
* cursor exactly where the branch's own args begin — including a literal
|
|
188
|
+
* `--json` the external binary itself accepts. */
|
|
189
|
+
function rawArgsAfterPath(rawTokens, path) {
|
|
190
|
+
let matched = 0;
|
|
191
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
192
|
+
if (matched === path.length)
|
|
193
|
+
return rawTokens.slice(i);
|
|
194
|
+
if (rawTokens[i] === '--json')
|
|
195
|
+
continue;
|
|
196
|
+
if (rawTokens[i] === path[matched])
|
|
197
|
+
matched++;
|
|
198
|
+
}
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
/** Forward `args` to an external binary verbatim: stdio inherited (streaming
|
|
202
|
+
* preserved), and the child's exit code propagated. Never returns — either
|
|
203
|
+
* exits with the child's status or throws a structured CrtrError (ENOENT ->
|
|
204
|
+
* install hint) for the caller's existing error handler to render. */
|
|
205
|
+
function runPassthrough(passthrough, args) {
|
|
206
|
+
const result = spawnSync(passthrough.bin, args, { stdio: 'inherit' });
|
|
207
|
+
if (result.error !== undefined) {
|
|
208
|
+
// Any launch-time failure (not found, not executable, ...) means this
|
|
209
|
+
// binary isn't usable from here — surface one structured error with the
|
|
210
|
+
// install hint rather than branching on a specific errno. (Node/libuv can
|
|
211
|
+
// report EACCES instead of ENOENT for a genuinely-missing binary when PATH
|
|
212
|
+
// contains malformed entries — e.g. a literal `~/...` segment — so a
|
|
213
|
+
// ENOENT-only check would miss real missing-binary cases on such hosts.)
|
|
214
|
+
const err = result.error;
|
|
215
|
+
throw new CrtrError('binary_missing', `${passthrough.bin} could not be run (${err.code ?? err.message})`, ExitCode.USAGE, {
|
|
216
|
+
received: passthrough.bin,
|
|
217
|
+
next: passthrough.installHint,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (result.status !== null) {
|
|
221
|
+
process.exit(result.status);
|
|
222
|
+
}
|
|
223
|
+
if (result.signal !== null) {
|
|
224
|
+
// Signal-terminated: exit(1) would break the transparent exit-propagation
|
|
225
|
+
// contract (a direct shell invocation would show 128+signal, e.g. 143 for
|
|
226
|
+
// SIGTERM). Map to the standard 128+signal-number convention.
|
|
227
|
+
const signalNumber = osConstants.signals[result.signal];
|
|
228
|
+
if (signalNumber !== undefined) {
|
|
229
|
+
process.exit(128 + signalNumber);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Truly impossible per Node's spawnSync contract (status and signal are
|
|
233
|
+
// mutually exclusive and one is always set on a completed run) — fallback only.
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
172
236
|
/** Build a structured unknown-path error. Names valid children of the deepest
|
|
173
237
|
* matched node and names the entry command per the spec. The entry command is
|
|
174
238
|
* the full path to the matched node (not just its local name), so the recovery
|
|
@@ -367,6 +431,14 @@ export async function runCli(root, argv) {
|
|
|
367
431
|
}
|
|
368
432
|
const { node, path, remaining } = walk(root, tokens);
|
|
369
433
|
try {
|
|
434
|
+
// Passthrough branches (e.g. `crtr capture`) opt out of the tree model
|
|
435
|
+
// entirely: forward every token after the branch verbatim to an external
|
|
436
|
+
// binary before any of the normal unknown-path / help / dispatch logic
|
|
437
|
+
// runs, so `crtr capture -h`, `crtr capture <args>`, and bare `crtr capture`
|
|
438
|
+
// all reach the external CLI untouched.
|
|
439
|
+
if (node.kind === 'branch' && node.passthrough !== undefined) {
|
|
440
|
+
runPassthrough(node.passthrough, rawArgsAfterPath(rawTokens, path));
|
|
441
|
+
}
|
|
370
442
|
// A root/branch node's `remaining` is only non-empty because walk hit
|
|
371
443
|
// -h/--help or an unmatched child name (a leaf always consumes the rest
|
|
372
444
|
// of the path itself). So a non-help leading token here is always the
|
|
@@ -27,7 +27,10 @@ export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): bool
|
|
|
27
27
|
* is covered). Default is the MRU covering profile.
|
|
28
28
|
* - Non-interactive: auto-pick the MRU covering profile, no prompt.
|
|
29
29
|
* 4. Else — nothing covers cwd walking all the way up. Interactive: prompt to
|
|
30
|
-
* create a profile here
|
|
30
|
+
* create a profile here, select an existing profile (a searchable list of
|
|
31
|
+
* every profile, for when you started somewhere none of them cover and
|
|
32
|
+
* can't recall the exact name — the pick then offers to adopt cwd), or use
|
|
33
|
+
* the root profile. Non-interactive (no TTY):
|
|
31
34
|
* default to the root profile and print the recovery instruction to STDERR —
|
|
32
35
|
* never stdout, which the caller may be piping. */
|
|
33
36
|
export declare function selectProfileForCwd(cwd: string, explicitProfile?: string | null, forcePicker?: boolean): Promise<string>;
|