@crouton-kit/crouter 0.3.66 → 0.3.68
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-memory/05-kinds/developer/00-base.md +1 -1
- package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
- package/dist/clients/attach/attach-cmd.d.ts +30 -2
- package/dist/clients/attach/attach-cmd.js +624 -624
- package/dist/clients/attach/canvas-panels.js +9 -3
- package/dist/clients/attach/graph-overlay.d.ts +5 -0
- package/dist/clients/attach/graph-overlay.js +40 -7
- package/dist/clients/attach/input-controller.d.ts +9 -0
- package/dist/clients/attach/input-controller.js +47 -8
- package/dist/clients/attach/slash-commands.d.ts +11 -4
- package/dist/clients/attach/slash-commands.js +44 -9
- package/dist/clients/attach/transport-relay.d.ts +5 -5
- package/dist/clients/attach/transport-relay.js +3 -30
- package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
- package/dist/commands/__tests__/canvas-config.test.js +107 -0
- package/dist/commands/canvas-browse.js +26 -1
- package/dist/commands/canvas-config.d.ts +2 -0
- package/dist/commands/canvas-config.js +210 -0
- package/dist/commands/canvas-use.d.ts +1 -0
- package/dist/commands/canvas-use.js +63 -0
- package/dist/commands/canvas.js +4 -2
- package/dist/commands/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -0
- package/dist/commands/node.js +41 -11
- package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
- package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
- package/dist/core/__tests__/revive.test.js +70 -1
- package/dist/core/__tests__/secrets.test.d.ts +1 -0
- package/dist/core/__tests__/secrets.test.js +55 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
- package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
- package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
- package/dist/core/canvas/browse/app.d.ts +14 -0
- package/dist/core/canvas/browse/app.js +97 -15
- package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
- package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +3 -1
- package/dist/core/canvas/nav-model.d.ts +19 -10
- package/dist/core/canvas/nav-model.js +30 -12
- package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
- package/dist/core/canvas/remote-canvas-source.js +222 -0
- package/dist/core/canvas/remote-event-stream.d.ts +24 -0
- package/dist/core/canvas/remote-event-stream.js +94 -0
- package/dist/core/canvas/render.d.ts +13 -1
- package/dist/core/canvas/render.js +56 -37
- package/dist/core/canvas/source.d.ts +9 -0
- package/dist/core/canvas/source.js +15 -0
- package/dist/core/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/config.js +4 -3
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/node-env.test.js +91 -0
- package/dist/core/runtime/nodes.js +10 -0
- package/dist/core/runtime/revive.js +176 -139
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/secrets.d.ts +25 -0
- package/dist/core/secrets.js +55 -0
- package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
- package/dist/core/view/__tests__/transport-remote.test.js +95 -0
- package/dist/core/view/remote-canvas-target.d.ts +2 -1
- package/dist/core/view/remote-canvas-target.js +12 -7
- package/dist/core/view/transport-local.js +12 -3
- package/dist/core/view/transport-remote.d.ts +2 -0
- package/dist/core/view/transport-remote.js +53 -0
- package/dist/types.d.ts +19 -11
- package/dist/types.js +1 -1
- package/dist/web-client/assets/{index-BvzxXXGU.js → index-CnF5r8ky.js} +18 -18
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// remote-canvas-target.test.ts — config normalization for
|
|
2
|
+
// `remoteCanvas.targets.<name>` (`../config.ts`'s normalizeRemoteCanvasTarget,
|
|
3
|
+
// exercised through the public readConfig/updateConfig surface — it isn't
|
|
4
|
+
// exported directly) plus `resolveRemoteCanvasTarget`'s error paths
|
|
5
|
+
// (`../view/remote-canvas-target.ts`). The one load-bearing invariant here:
|
|
6
|
+
// an error message about a missing/unknown target must never leak a token
|
|
7
|
+
// substring, even though it's guiding the agent toward the exact command
|
|
8
|
+
// that would set one.
|
|
9
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { readConfig, updateConfig } from '../config.js';
|
|
15
|
+
import { setRelayToken } from '../secrets.js';
|
|
16
|
+
import { getRemoteCanvasTarget, resolveRemoteCanvasTarget } from '../view/remote-canvas-target.js';
|
|
17
|
+
let home;
|
|
18
|
+
let realHome;
|
|
19
|
+
before(() => {
|
|
20
|
+
realHome = process.env['HOME'];
|
|
21
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-remote-target-'));
|
|
22
|
+
process.env['HOME'] = home;
|
|
23
|
+
});
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
rmSync(home, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
after(() => {
|
|
28
|
+
rmSync(home, { recursive: true, force: true });
|
|
29
|
+
if (realHome !== undefined)
|
|
30
|
+
process.env['HOME'] = realHome;
|
|
31
|
+
else
|
|
32
|
+
delete process.env['HOME'];
|
|
33
|
+
});
|
|
34
|
+
test('normalizeRemoteCanvasTarget accepts a valid relayTokenRef entry', () => {
|
|
35
|
+
updateConfig('user', (cfg) => {
|
|
36
|
+
cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
|
|
37
|
+
});
|
|
38
|
+
const target = getRemoteCanvasTarget('cloud', 'user');
|
|
39
|
+
assert.deepEqual(target, { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' });
|
|
40
|
+
});
|
|
41
|
+
test('normalizeRemoteCanvasTarget drops an entry missing relayTokenRef instead of throwing', () => {
|
|
42
|
+
updateConfig('user', (cfg) => {
|
|
43
|
+
// Bypass the RemoteCanvasTarget type to write a malformed raw entry, the
|
|
44
|
+
// same way a hand-edited config.json could.
|
|
45
|
+
cfg.remoteCanvas.targets['broken'] = { previewEndpoint: 'https://x.preview.bl.run' };
|
|
46
|
+
});
|
|
47
|
+
const cfg = readConfig('user');
|
|
48
|
+
assert.equal(cfg.remoteCanvas.targets['broken'], undefined);
|
|
49
|
+
});
|
|
50
|
+
test('normalizeRemoteCanvasTarget drops an entry with an empty previewEndpoint', () => {
|
|
51
|
+
updateConfig('user', (cfg) => {
|
|
52
|
+
cfg.remoteCanvas.targets['broken'] = { previewEndpoint: '', relayTokenRef: 'x' };
|
|
53
|
+
});
|
|
54
|
+
assert.equal(readConfig('user').remoteCanvas.targets['broken'], undefined);
|
|
55
|
+
});
|
|
56
|
+
test('resolveRemoteCanvasTarget on an unknown name throws a usage error that never contains a token substring', () => {
|
|
57
|
+
updateConfig('user', (cfg) => {
|
|
58
|
+
cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
|
|
59
|
+
});
|
|
60
|
+
setRelayToken('cloud', 'super-secret-token', 'user');
|
|
61
|
+
assert.throws(() => resolveRemoteCanvasTarget('no-such-target', 'user'), (err) => {
|
|
62
|
+
assert.ok(err instanceof Error);
|
|
63
|
+
assert.match(err.message, /no remote canvas target named 'no-such-target'/);
|
|
64
|
+
assert.match(err.message, /configured: cloud/);
|
|
65
|
+
assert.ok(!err.message.includes('super-secret-token'));
|
|
66
|
+
return true;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
test('resolveRemoteCanvasTarget on a target with no stored token throws without leaking a token substring', () => {
|
|
70
|
+
updateConfig('user', (cfg) => {
|
|
71
|
+
cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
|
|
72
|
+
});
|
|
73
|
+
// Deliberately no setRelayToken call — the secret was never stored.
|
|
74
|
+
assert.throws(() => resolveRemoteCanvasTarget('cloud', 'user'), (err) => {
|
|
75
|
+
assert.ok(err instanceof Error);
|
|
76
|
+
assert.match(err.message, /has no stored relay token/);
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
test('resolveRemoteCanvasTarget on a fully configured target returns the RelayTarget with the real token', () => {
|
|
81
|
+
updateConfig('user', (cfg) => {
|
|
82
|
+
cfg.remoteCanvas.targets['cloud'] = { previewEndpoint: 'https://x.preview.bl.run', relayTokenRef: 'cloud' };
|
|
83
|
+
});
|
|
84
|
+
setRelayToken('cloud', 'super-secret-token', 'user');
|
|
85
|
+
const relay = resolveRemoteCanvasTarget('cloud', 'user');
|
|
86
|
+
assert.deepEqual(relay, { previewEndpoint: 'https://x.preview.bl.run', relayToken: 'super-secret-token' });
|
|
87
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// secrets.test.ts — the 0600 relay-token store (`../secrets.ts`). Covers the
|
|
2
|
+
// round-trip (set/get/delete) and the one load-bearing invariant a JSON
|
|
3
|
+
// config file doesn't get for free: the file is written 0600, not the
|
|
4
|
+
// process's default umask-derived mode.
|
|
5
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { mkdtempSync, rmSync, statSync, existsSync, chmodSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { readSecrets, writeSecrets, getRelayToken, setRelayToken, deleteRelayToken, fingerprint } from '../secrets.js';
|
|
11
|
+
let home;
|
|
12
|
+
let realHome;
|
|
13
|
+
before(() => {
|
|
14
|
+
realHome = process.env['HOME'];
|
|
15
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-secrets-'));
|
|
16
|
+
process.env['HOME'] = home;
|
|
17
|
+
});
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
rmSync(home, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
after(() => {
|
|
22
|
+
rmSync(home, { recursive: true, force: true });
|
|
23
|
+
if (realHome !== undefined)
|
|
24
|
+
process.env['HOME'] = realHome;
|
|
25
|
+
else
|
|
26
|
+
delete process.env['HOME'];
|
|
27
|
+
});
|
|
28
|
+
test('a scope with no secrets.json yet reads as empty', () => {
|
|
29
|
+
assert.deepEqual(readSecrets('user'), { relayTokens: {} });
|
|
30
|
+
assert.equal(getRelayToken('cloud', 'user'), undefined);
|
|
31
|
+
});
|
|
32
|
+
test('set/get/delete round-trip a token by ref', () => {
|
|
33
|
+
setRelayToken('cloud', 'sekret-token-value', 'user');
|
|
34
|
+
assert.equal(getRelayToken('cloud', 'user'), 'sekret-token-value');
|
|
35
|
+
deleteRelayToken('cloud', 'user');
|
|
36
|
+
assert.equal(getRelayToken('cloud', 'user'), undefined);
|
|
37
|
+
});
|
|
38
|
+
test('writeSecrets writes secrets.json 0600, even over a pre-existing looser-mode file', () => {
|
|
39
|
+
writeSecrets('user', { relayTokens: { a: 'x' } });
|
|
40
|
+
const p = join(home, '.crouter', 'secrets.json');
|
|
41
|
+
assert.ok(existsSync(p));
|
|
42
|
+
assert.equal(statSync(p).mode & 0o777, 0o600);
|
|
43
|
+
// Re-write over an existing file that had a looser mode — chmodSync must
|
|
44
|
+
// re-tighten it, not just rely on the initial writeFileSync mode.
|
|
45
|
+
chmodSync(p, 0o644);
|
|
46
|
+
assert.equal(statSync(p).mode & 0o777, 0o644);
|
|
47
|
+
writeSecrets('user', { relayTokens: { a: 'x', b: 'y' } });
|
|
48
|
+
assert.equal(statSync(p).mode & 0o777, 0o600);
|
|
49
|
+
});
|
|
50
|
+
test('fingerprint is the sha256(token) hex prefix — pinned, so a reversible encoding fails', () => {
|
|
51
|
+
assert.equal(fingerprint('sekret-token-value'), '662bda51b31e');
|
|
52
|
+
assert.notEqual(fingerprint('a-different-token'), fingerprint('sekret-token-value'));
|
|
53
|
+
assert.match(fingerprint('sekret-token-value'), /^[0-9a-f]{12}$/);
|
|
54
|
+
assert.ok(!fingerprint('sekret-token-value').includes('sekret-token-value'));
|
|
55
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// remote-canvas-source.test.ts — `RemoteCanvasSource` (`../remote-canvas-source.ts`)
|
|
2
|
+
// over its real wire contract: a POST to `/__crtr/source` carrying
|
|
3
|
+
// `{kind:'exec', bin, args}` (see `../../view/transport-remote.ts`), bearer-
|
|
4
|
+
// authed. Mirrors `src/clients/attach/__tests__/transport-relay.test.ts`'s
|
|
5
|
+
// `startMockRelay` shape — a plain `node:http` server standing in for the
|
|
6
|
+
// remote canvas's `/__crtr/source` endpoint, recording every request so
|
|
7
|
+
// assertions land on the wire (argv shape, token placement), not just
|
|
8
|
+
// behavior.
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { RemoteCanvasSource } from '../remote-canvas-source.js';
|
|
13
|
+
import { dashboardRowsAllFromSource, renderForestFromSource } from '../render.js';
|
|
14
|
+
/** Stand up a bare `node:http` server on an ephemeral loopback port — the
|
|
15
|
+
* `/__crtr/source` endpoint a `RemoteCanvasSource` POSTs to. `route` decides
|
|
16
|
+
* the response per captured argv; every request is recorded first so a test
|
|
17
|
+
* can assert on the raw wire (headers, body) independent of what `route`
|
|
18
|
+
* returns. */
|
|
19
|
+
function startMockCrtrServer(route) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const requests = [];
|
|
22
|
+
const server = createServer((req, res) => {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
req.on('data', (c) => chunks.push(c));
|
|
25
|
+
req.on('end', () => {
|
|
26
|
+
const bodyText = Buffer.concat(chunks).toString('utf8');
|
|
27
|
+
const parsed = JSON.parse(bodyText);
|
|
28
|
+
// Reject any request that does not match the exec-only shelling contract
|
|
29
|
+
// (`{kind:'exec', bin:'crtr'}`) instead of silently routing it —
|
|
30
|
+
// RemoteCanvasSource must never send anything else over this wire.
|
|
31
|
+
if (parsed.kind !== 'exec' || parsed.bin !== 'crtr') {
|
|
32
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
33
|
+
res.end(JSON.stringify({ ok: false, exitCode: 1, stdout: '', stderr: `unexpected request shape: kind=${parsed.kind} bin=${parsed.bin}` }));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
requests.push({
|
|
37
|
+
authHeader: req.headers.authorization,
|
|
38
|
+
url: req.url ?? '',
|
|
39
|
+
method: req.method,
|
|
40
|
+
headers: req.headers,
|
|
41
|
+
rawBody: bodyText,
|
|
42
|
+
kind: parsed.kind,
|
|
43
|
+
bin: parsed.bin,
|
|
44
|
+
args: parsed.args,
|
|
45
|
+
});
|
|
46
|
+
const result = route(parsed.args);
|
|
47
|
+
if (result.kind === 'json') {
|
|
48
|
+
res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
res.writeHead(result.status, { 'content-type': result.contentType ?? 'text/plain' });
|
|
53
|
+
res.end(result.body);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
server.listen(0, '127.0.0.1', () => {
|
|
58
|
+
const addr = server.address();
|
|
59
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
60
|
+
resolve({
|
|
61
|
+
url: `http://127.0.0.1:${port}`,
|
|
62
|
+
requests,
|
|
63
|
+
close: () => new Promise((res) => server.close(() => res())),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function meta(id, over = {}) {
|
|
69
|
+
return {
|
|
70
|
+
node_id: id,
|
|
71
|
+
name: id,
|
|
72
|
+
created: new Date().toISOString(),
|
|
73
|
+
cwd: '/tmp/work',
|
|
74
|
+
kind: 'general',
|
|
75
|
+
mode: 'base',
|
|
76
|
+
lifecycle: 'terminal',
|
|
77
|
+
status: 'active',
|
|
78
|
+
...over,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function showResponse(id, reports = []) {
|
|
82
|
+
return {
|
|
83
|
+
node: meta(id),
|
|
84
|
+
reports: reports.map((r) => ({ node_id: r.node_id, name: r.node_id, kind: 'general', status: 'active', active: r.active })),
|
|
85
|
+
managers: [],
|
|
86
|
+
artifacts: { report: 0, doc: 0, roadmap: 0 },
|
|
87
|
+
pending_triggers: 0,
|
|
88
|
+
paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
|
|
89
|
+
follow_up: '',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
test('every request POSTs argv leading with --json, and the token appears ONLY in the Authorization header', async (t) => {
|
|
93
|
+
const server = await startMockCrtrServer((args) => {
|
|
94
|
+
if (args[0] === '--json' && args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
|
|
95
|
+
return { kind: 'json', body: showResponse(args[4]) };
|
|
96
|
+
}
|
|
97
|
+
return { kind: 'json', status: 500, body: {} };
|
|
98
|
+
});
|
|
99
|
+
t.after(() => server.close());
|
|
100
|
+
const source = new RemoteCanvasSource(server.url, 'secret-relay-token');
|
|
101
|
+
await source.getNode('a');
|
|
102
|
+
assert.equal(server.requests.length, 1);
|
|
103
|
+
const req = server.requests[0];
|
|
104
|
+
assert.equal(req.method, 'POST');
|
|
105
|
+
assert.equal(req.url, '/__crtr/source');
|
|
106
|
+
assert.equal(req.authHeader, 'Bearer secret-relay-token');
|
|
107
|
+
assert.equal(req.kind, 'exec', 'RemoteCanvasSource must only ever send the exec contract');
|
|
108
|
+
assert.equal(req.bin, 'crtr', 'RemoteCanvasSource must only ever shell the existing crtr binary, never an ad-hoc command');
|
|
109
|
+
assert.deepEqual(req.args.slice(0, 4), ['--json', 'node', 'inspect', 'show']);
|
|
110
|
+
assert.ok(!req.url.includes('secret-relay-token'));
|
|
111
|
+
assert.ok(!JSON.stringify(req.args).includes('secret-relay-token'));
|
|
112
|
+
assert.ok(!req.rawBody.includes('secret-relay-token'), 'the raw POST body must not carry the token');
|
|
113
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
114
|
+
if (k.toLowerCase() === 'authorization')
|
|
115
|
+
continue;
|
|
116
|
+
assert.ok(!String(v).includes('secret-relay-token'), `header ${k} must not carry the token`);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
test('getNode/getRow project the show response correctly', async (t) => {
|
|
120
|
+
const server = await startMockCrtrServer((args) => {
|
|
121
|
+
if (args[3] === 'show')
|
|
122
|
+
return { kind: 'json', body: showResponse('a') };
|
|
123
|
+
return { kind: 'json', status: 500, body: {} };
|
|
124
|
+
});
|
|
125
|
+
t.after(() => server.close());
|
|
126
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
127
|
+
const node = await source.getNode('a');
|
|
128
|
+
assert.equal(node?.node_id, 'a');
|
|
129
|
+
assert.equal(node?.status, 'active');
|
|
130
|
+
const row = await source.getRow('a');
|
|
131
|
+
assert.equal(row?.node_id, 'a');
|
|
132
|
+
assert.equal(row?.kind, 'general');
|
|
133
|
+
assert.equal(row?.host_kind, null);
|
|
134
|
+
assert.equal(row?.profile_id, null);
|
|
135
|
+
});
|
|
136
|
+
test('listNodes passes --status only when a filter is given', async (t) => {
|
|
137
|
+
const seenArgs = [];
|
|
138
|
+
const server = await startMockCrtrServer((args) => {
|
|
139
|
+
seenArgs.push(args);
|
|
140
|
+
const nodes = [];
|
|
141
|
+
return { kind: 'json', body: { nodes } };
|
|
142
|
+
});
|
|
143
|
+
t.after(() => server.close());
|
|
144
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
145
|
+
await source.listNodes();
|
|
146
|
+
assert.deepEqual(seenArgs[0], ['--json', 'node', 'inspect', 'list']);
|
|
147
|
+
await source.listNodes({ status: 'active' });
|
|
148
|
+
assert.deepEqual(seenArgs[1], ['--json', 'node', 'inspect', 'list', '--status', 'active']);
|
|
149
|
+
await source.listNodes({ status: ['active', 'idle'] });
|
|
150
|
+
assert.deepEqual(seenArgs[2], ['--json', 'node', 'inspect', 'list', '--status', 'active,idle']);
|
|
151
|
+
});
|
|
152
|
+
test('subscriptionsOf/subscribersOf map reports/managers with created: \'\'', async (t) => {
|
|
153
|
+
const server = await startMockCrtrServer((args) => {
|
|
154
|
+
if (args[3] === 'show') {
|
|
155
|
+
return {
|
|
156
|
+
kind: 'json',
|
|
157
|
+
body: {
|
|
158
|
+
...showResponse(args[4], [{ node_id: 'child', active: true }]),
|
|
159
|
+
managers: [{ node_id: 'boss', name: 'boss', kind: 'general', status: 'active', active: false }],
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { kind: 'json', status: 500, body: {} };
|
|
164
|
+
});
|
|
165
|
+
t.after(() => server.close());
|
|
166
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
167
|
+
const subs = await source.subscriptionsOf('a');
|
|
168
|
+
assert.deepEqual(subs, [{ node_id: 'child', active: true, created: '' }]);
|
|
169
|
+
const managers = await source.subscribersOf('a');
|
|
170
|
+
assert.deepEqual(managers, [{ node_id: 'boss', active: false, created: '' }]);
|
|
171
|
+
});
|
|
172
|
+
test('view BFS traverses subscriptionsOf and is cycle-safe', async (t) => {
|
|
173
|
+
// root -> a -> b -> a (cycle back to a).
|
|
174
|
+
const graph = {
|
|
175
|
+
root: [{ node_id: 'a', active: true }],
|
|
176
|
+
a: [{ node_id: 'b', active: true }],
|
|
177
|
+
b: [{ node_id: 'a', active: true }],
|
|
178
|
+
};
|
|
179
|
+
const server = await startMockCrtrServer((args) => {
|
|
180
|
+
if (args[3] === 'show') {
|
|
181
|
+
const id = args[4];
|
|
182
|
+
return { kind: 'json', body: showResponse(id, graph[id] ?? []) };
|
|
183
|
+
}
|
|
184
|
+
return { kind: 'json', status: 500, body: {} };
|
|
185
|
+
});
|
|
186
|
+
t.after(() => server.close());
|
|
187
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
188
|
+
const result = await source.view('root');
|
|
189
|
+
assert.deepEqual([...result].sort(), ['a', 'b']);
|
|
190
|
+
});
|
|
191
|
+
test('a 500 response, a non-JSON body, and a non-zero exitCode all degrade gracefully instead of throwing', async (t) => {
|
|
192
|
+
const server = await startMockCrtrServer((args) => {
|
|
193
|
+
if (args.includes('http500'))
|
|
194
|
+
return { kind: 'json', status: 500, body: {} };
|
|
195
|
+
if (args.includes('badjson'))
|
|
196
|
+
return { kind: 'raw', status: 200, body: 'not json', contentType: 'application/json' };
|
|
197
|
+
if (args.includes('nonzero-exit')) {
|
|
198
|
+
// stdout is syntactically valid JSON that WOULD be accepted if execJson
|
|
199
|
+
// ignored exitCode — the assertion below only passes because the
|
|
200
|
+
// nonzero exitCode is honored and this payload is discarded.
|
|
201
|
+
const poisoned = JSON.stringify({ counts: { 'nonzero-exit': 7 } });
|
|
202
|
+
return { kind: 'raw', status: 200, body: JSON.stringify({ ok: true, exitCode: 1, stdout: poisoned, stderr: 'boom' }), contentType: 'application/json' };
|
|
203
|
+
}
|
|
204
|
+
return { kind: 'json', status: 500, body: {} };
|
|
205
|
+
});
|
|
206
|
+
t.after(() => server.close());
|
|
207
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
208
|
+
assert.equal(await source.getNode('http500'), null);
|
|
209
|
+
assert.deepEqual(await source.listNodes(), []);
|
|
210
|
+
assert.deepEqual(await source.askCountsForView('badjson'), {});
|
|
211
|
+
assert.deepEqual(await source.askCountsForView('nonzero-exit'), {});
|
|
212
|
+
});
|
|
213
|
+
test('malformed success JSON (exit 0, wrong shape) degrades cleanly instead of returning a shape a consumer would crash on', async (t) => {
|
|
214
|
+
const server = await startMockCrtrServer((args) => {
|
|
215
|
+
if (args[3] === 'show') {
|
|
216
|
+
const id = args[4];
|
|
217
|
+
if (id === 'empty-object')
|
|
218
|
+
return { kind: 'json', body: {} };
|
|
219
|
+
if (id === 'null-node')
|
|
220
|
+
return { kind: 'json', body: { node: null, reports: [], managers: [] } };
|
|
221
|
+
if (id === 'reports-not-array')
|
|
222
|
+
return { kind: 'json', body: { node: meta('a'), reports: 'nope', managers: [] } };
|
|
223
|
+
if (id === 'node-missing-id')
|
|
224
|
+
return { kind: 'json', body: { node: { name: 'a' }, reports: [], managers: [] } };
|
|
225
|
+
return { kind: 'json', body: showResponse(id) };
|
|
226
|
+
}
|
|
227
|
+
if (args[3] === 'list') {
|
|
228
|
+
return { kind: 'json', body: { nodes: 'not-an-array' } };
|
|
229
|
+
}
|
|
230
|
+
if (args[2] === 'attention') {
|
|
231
|
+
return { kind: 'json', body: { counts: 'not-an-object' } };
|
|
232
|
+
}
|
|
233
|
+
return { kind: 'json', status: 500, body: {} };
|
|
234
|
+
});
|
|
235
|
+
t.after(() => server.close());
|
|
236
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
237
|
+
// `{}` — no `node` key at all. Without shape validation this would cast
|
|
238
|
+
// through as a truthy "valid" RemoteShowResponse (an object with `.node ===
|
|
239
|
+
// undefined`), and a consumer like `node inspect show`'s remote passthrough
|
|
240
|
+
// branch (src/commands/node.ts) treats any non-null return as found, then
|
|
241
|
+
// its renderer dereferences fields on `undefined` and throws.
|
|
242
|
+
assert.equal(await source.getNode('empty-object'), null, 'a shape with no node key must degrade to null, not a malformed object');
|
|
243
|
+
assert.equal(await source.rawShow('empty-object'), null);
|
|
244
|
+
assert.equal(await source.getNode('null-node'), null, '`node: null` must degrade to null');
|
|
245
|
+
assert.deepEqual(await source.subscriptionsOf('reports-not-array'), [], 'a non-array reports field must degrade to []');
|
|
246
|
+
assert.equal(await source.getNode('node-missing-id'), null, 'a node object missing node_id must degrade to null');
|
|
247
|
+
// `listNodes()` with a non-array `nodes` field must degrade to [], never
|
|
248
|
+
// throw or return the malformed value.
|
|
249
|
+
assert.deepEqual(await source.listNodes(), []);
|
|
250
|
+
// `askCountsForView()` with a non-object `counts` field must degrade to {}.
|
|
251
|
+
assert.deepEqual(await source.askCountsForView('root'), {});
|
|
252
|
+
});
|
|
253
|
+
function fullNodeRow(id, over = {}) {
|
|
254
|
+
return {
|
|
255
|
+
node_id: id,
|
|
256
|
+
name: id,
|
|
257
|
+
kind: 'general',
|
|
258
|
+
mode: 'base',
|
|
259
|
+
lifecycle: 'terminal',
|
|
260
|
+
status: 'active',
|
|
261
|
+
cwd: '/tmp/work',
|
|
262
|
+
host_kind: null,
|
|
263
|
+
profile_id: null,
|
|
264
|
+
parent: null,
|
|
265
|
+
created: new Date().toISOString(),
|
|
266
|
+
intent: null,
|
|
267
|
+
pi_pid: null,
|
|
268
|
+
pi_pid_identity: null,
|
|
269
|
+
window: null,
|
|
270
|
+
tmux_session: null,
|
|
271
|
+
pane: null,
|
|
272
|
+
...over,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
test('listNodes drops malformed row elements (null, missing required fields) instead of casting them through', async (t) => {
|
|
276
|
+
const server = await startMockCrtrServer((args) => {
|
|
277
|
+
if (args[3] === 'list') {
|
|
278
|
+
return {
|
|
279
|
+
kind: 'json',
|
|
280
|
+
body: { nodes: [null, { node_id: 'partial-only' }, fullNodeRow('good')] },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
return { kind: 'json', status: 500, body: {} };
|
|
284
|
+
});
|
|
285
|
+
t.after(() => server.close());
|
|
286
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
287
|
+
const nodes = await source.listNodes();
|
|
288
|
+
assert.deepEqual(nodes.map((n) => n.node_id), ['good'], 'a null element and a row missing required string fields must both be dropped, leaving only the well-formed row');
|
|
289
|
+
});
|
|
290
|
+
test('isValidShowResponse rejects a payload missing/malformed on artifacts/pending_triggers/paths/follow_up (Phase 3 review Minor 2)', async (t) => {
|
|
291
|
+
const server = await startMockCrtrServer((args) => {
|
|
292
|
+
if (args[3] !== 'show')
|
|
293
|
+
return { kind: 'json', status: 500, body: {} };
|
|
294
|
+
const id = args[4];
|
|
295
|
+
const valid = showResponse(id);
|
|
296
|
+
switch (id) {
|
|
297
|
+
case 'missing-artifacts': {
|
|
298
|
+
const { artifacts: _artifacts, ...rest } = valid;
|
|
299
|
+
return { kind: 'json', body: rest };
|
|
300
|
+
}
|
|
301
|
+
case 'artifacts-wrong-type':
|
|
302
|
+
return { kind: 'json', body: { ...valid, artifacts: { report: '0', doc: 0, roadmap: 0 } } };
|
|
303
|
+
case 'missing-pending-triggers': {
|
|
304
|
+
const { pending_triggers: _pt, ...rest } = valid;
|
|
305
|
+
return { kind: 'json', body: rest };
|
|
306
|
+
}
|
|
307
|
+
case 'paths-session-file-undefined': {
|
|
308
|
+
const { session_file: _sf, ...restPaths } = valid.paths;
|
|
309
|
+
return { kind: 'json', body: { ...valid, paths: restPaths } };
|
|
310
|
+
}
|
|
311
|
+
case 'paths-session-file-null':
|
|
312
|
+
return { kind: 'json', body: { ...valid, paths: { ...valid.paths, session_file: null } } };
|
|
313
|
+
case 'missing-follow-up': {
|
|
314
|
+
const { follow_up: _fu, ...rest } = valid;
|
|
315
|
+
return { kind: 'json', body: rest };
|
|
316
|
+
}
|
|
317
|
+
case 'fully-valid':
|
|
318
|
+
return { kind: 'json', body: valid };
|
|
319
|
+
default:
|
|
320
|
+
return { kind: 'json', status: 500, body: {} };
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
t.after(() => server.close());
|
|
324
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
325
|
+
assert.equal(await source.rawShow('missing-artifacts'), null, 'a payload with no artifacts key must degrade to null');
|
|
326
|
+
assert.equal(await source.rawShow('artifacts-wrong-type'), null, 'artifacts.report as a string must degrade to null');
|
|
327
|
+
assert.equal(await source.rawShow('missing-pending-triggers'), null, 'a payload with no pending_triggers key must degrade to null');
|
|
328
|
+
assert.equal(await source.rawShow('paths-session-file-undefined'), null, 'paths.session_file entirely absent (not null) must degrade to null');
|
|
329
|
+
assert.notEqual(await source.rawShow('paths-session-file-null'), null, 'paths.session_file explicitly null (no session yet) must be ACCEPTED');
|
|
330
|
+
assert.equal(await source.rawShow('missing-follow-up'), null, 'a payload with no follow_up key must degrade to null');
|
|
331
|
+
assert.notEqual(await source.rawShow('fully-valid'), null, 'the tightened validator must not false-reject the real, fully-valid shape');
|
|
332
|
+
});
|
|
333
|
+
test('a malformed row element does not crash the browse snapshot path (dashboardRowsAllFromSource / renderForestFromSource)', async (t) => {
|
|
334
|
+
const server = await startMockCrtrServer((args) => {
|
|
335
|
+
if (args[3] === 'list') {
|
|
336
|
+
return { kind: 'json', body: { nodes: [null, { node_id: 'partial-only' }, fullNodeRow('good')] } };
|
|
337
|
+
}
|
|
338
|
+
if (args[3] === 'show') {
|
|
339
|
+
return { kind: 'json', body: showResponse('good') };
|
|
340
|
+
}
|
|
341
|
+
return { kind: 'json', status: 500, body: {} };
|
|
342
|
+
});
|
|
343
|
+
t.after(() => server.close());
|
|
344
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
345
|
+
// `browse/app.ts`'s `rebuildSnapshot` calls `listNodes()` and dereferences
|
|
346
|
+
// `n.parent`/`n.kind`/`n.node_id` on every row, and `dashboardRowsAllFromSource`
|
|
347
|
+
// reaches the same shape via `dashboardRowFromNodeRow` — both rely on
|
|
348
|
+
// `listNodes()` having already dropped the malformed `null` element.
|
|
349
|
+
const rows = await dashboardRowsAllFromSource(source);
|
|
350
|
+
assert.deepEqual(rows.map((r) => r.node_id), ['good']);
|
|
351
|
+
const forest = await renderForestFromSource(source);
|
|
352
|
+
assert.ok(forest.includes('good'));
|
|
353
|
+
});
|
|
354
|
+
test('cache coalescing: two getNode calls inside the TTL hit the server once; invalidate() busts it', async (t) => {
|
|
355
|
+
let hitCount = 0;
|
|
356
|
+
const server = await startMockCrtrServer((args) => {
|
|
357
|
+
if (args[3] === 'show') {
|
|
358
|
+
hitCount += 1;
|
|
359
|
+
return { kind: 'json', body: showResponse(args[4]) };
|
|
360
|
+
}
|
|
361
|
+
return { kind: 'json', status: 500, body: {} };
|
|
362
|
+
});
|
|
363
|
+
t.after(() => server.close());
|
|
364
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
365
|
+
await source.getNode('a');
|
|
366
|
+
await source.getNode('a');
|
|
367
|
+
assert.equal(hitCount, 1);
|
|
368
|
+
source.invalidate();
|
|
369
|
+
await source.getNode('a');
|
|
370
|
+
assert.equal(hitCount, 2);
|
|
371
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|