@crouton-kit/crouter 0.3.65 → 0.3.67

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.
Files changed (83) hide show
  1. package/dist/builtin-memory/05-kinds/developer/00-base.md +1 -1
  2. package/dist/builtin-memory/05-kinds/developer/01-orchestrator.md +2 -0
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +24 -12
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +41 -16
  5. package/dist/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  7. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  8. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  9. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  10. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  11. package/dist/clients/attach/attach-cmd.js +632 -632
  12. package/dist/clients/attach/canvas-panels.js +9 -3
  13. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  14. package/dist/clients/attach/graph-overlay.js +65 -11
  15. package/dist/clients/attach/input-controller.d.ts +9 -0
  16. package/dist/clients/attach/input-controller.js +47 -8
  17. package/dist/clients/attach/slash-commands.d.ts +11 -4
  18. package/dist/clients/attach/slash-commands.js +44 -9
  19. package/dist/clients/attach/transport-relay.d.ts +5 -5
  20. package/dist/clients/attach/transport-relay.js +3 -30
  21. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  22. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  23. package/dist/commands/canvas-browse.js +26 -1
  24. package/dist/commands/canvas-config.d.ts +2 -0
  25. package/dist/commands/canvas-config.js +210 -0
  26. package/dist/commands/canvas-use.d.ts +1 -0
  27. package/dist/commands/canvas-use.js +63 -0
  28. package/dist/commands/canvas.js +4 -2
  29. package/dist/commands/node-lifecycle-revive.js +9 -2
  30. package/dist/commands/node.js +41 -11
  31. package/dist/core/__tests__/on-read-crouter-home-fence.test.d.ts +1 -0
  32. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +67 -0
  33. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  34. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  35. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  36. package/dist/core/__tests__/secrets.test.js +55 -0
  37. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  38. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  39. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  40. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  41. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  42. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  43. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  44. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  45. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  46. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  47. package/dist/core/canvas/browse/app.d.ts +14 -0
  48. package/dist/core/canvas/browse/app.js +97 -15
  49. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  50. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  51. package/dist/core/canvas/browse/render.d.ts +4 -0
  52. package/dist/core/canvas/browse/render.js +3 -1
  53. package/dist/core/canvas/nav-model.d.ts +19 -10
  54. package/dist/core/canvas/nav-model.js +30 -12
  55. package/dist/core/canvas/pid.d.ts +71 -13
  56. package/dist/core/canvas/pid.js +73 -29
  57. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  58. package/dist/core/canvas/remote-canvas-source.js +222 -0
  59. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  60. package/dist/core/canvas/remote-event-stream.js +94 -0
  61. package/dist/core/canvas/render.d.ts +13 -1
  62. package/dist/core/canvas/render.js +56 -37
  63. package/dist/core/canvas/source.d.ts +9 -0
  64. package/dist/core/canvas/source.js +15 -0
  65. package/dist/core/config.js +4 -3
  66. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  67. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  68. package/dist/core/runtime/nodes.js +10 -0
  69. package/dist/core/runtime/revive-all.js +8 -2
  70. package/dist/core/runtime/revive.js +18 -8
  71. package/dist/core/secrets.d.ts +25 -0
  72. package/dist/core/secrets.js +55 -0
  73. package/dist/core/substrate/on-read.js +13 -2
  74. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  75. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  76. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  77. package/dist/core/view/remote-canvas-target.js +12 -7
  78. package/dist/core/view/transport-remote.d.ts +2 -0
  79. package/dist/core/view/transport-remote.js +53 -0
  80. package/dist/daemon/crtrd.js +18 -8
  81. package/dist/types.d.ts +19 -11
  82. package/dist/types.js +1 -1
  83. package/package.json +1 -1
@@ -0,0 +1,67 @@
1
+ // The on-read POSITIONAL walk must never treat the crouter home (`~/.crouter`,
2
+ // the user scope root) as a project ancestor. Canvas runtime artifacts live at
3
+ // `~/.crouter/canvas/nodes/<id>/{reports,context}/...`, so a naive ancestor
4
+ // walk from such a read climbs THROUGH `~/.crouter` and would inject whatever
5
+ // lives at `~/.crouter/.crouter/memory/` — e.g. a differently-scoped node whose
6
+ // cwd resolved its private project store there. That leaks one node's private
7
+ // memory into every unrelated node that reads a canvas file. This test pins the
8
+ // fence: reading a canvas artifact must not surface a store nested in the
9
+ // crouter home.
10
+ //
11
+ // Run: node --import tsx/esm --test src/core/__tests__/on-read-crouter-home-fence.test.ts
12
+ import { test, before, beforeEach, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { closeDb } from '../canvas/db.js';
18
+ import { resetScopeCache } from '../scope.js';
19
+ import { spawnNode } from '../runtime/nodes.js';
20
+ import { renderOnReadDocs } from '../substrate/on-read.js';
21
+ let canvasHome;
22
+ let fakeHome;
23
+ let prevHomeEnv;
24
+ before(() => {
25
+ prevHomeEnv = process.env['HOME'];
26
+ });
27
+ beforeEach(() => {
28
+ closeDb();
29
+ resetScopeCache();
30
+ // A private user HOME so `homedir()`/`userScopeRoot()` resolve into our
31
+ // sandbox, not the real machine.
32
+ fakeHome = mkdtempSync(join(tmpdir(), 'crtr-fence-home-'));
33
+ process.env['HOME'] = fakeHome;
34
+ // The node graph (canvas.db) lives in its own CRTR_HOME so spawnNode does not
35
+ // touch the fake user home; the read path we test is constructed by hand.
36
+ canvasHome = mkdtempSync(join(tmpdir(), 'crtr-fence-canvas-'));
37
+ process.env['CRTR_HOME'] = canvasHome;
38
+ });
39
+ after(() => {
40
+ closeDb();
41
+ resetScopeCache();
42
+ rmSync(canvasHome, { recursive: true, force: true });
43
+ rmSync(fakeHome, { recursive: true, force: true });
44
+ delete process.env['CRTR_HOME'];
45
+ if (prevHomeEnv === undefined)
46
+ delete process.env['HOME'];
47
+ else
48
+ process.env['HOME'] = prevHomeEnv;
49
+ });
50
+ test('a store nested in the crouter home does not leak into a canvas-artifact read', () => {
51
+ const node = spawnNode({ kind: 'general', cwd: fakeHome, parent: null }).node_id;
52
+ // A private store misresolved into the crouter home: `~/.crouter/.crouter/memory/`.
53
+ const leakedStore = join(fakeHome, '.crouter', '.crouter', 'memory');
54
+ mkdirSync(leakedStore, { recursive: true });
55
+ writeFileSync(join(leakedStore, 'private-thing.md'), '---\nkind: knowledge\n' +
56
+ "when-and-why-to-read: When X, read this because Y.\n" +
57
+ 'file-read-visibility: content\n---\n' +
58
+ 'PRIVATE ASSISTANT MEMORY MUST NOT LEAK\n');
59
+ // A canvas runtime artifact under the crouter home — the shape of every node
60
+ // report/context file the harness auto-reads.
61
+ const reportFile = join(fakeHome, '.crouter', 'canvas', 'nodes', 'somenode', 'reports', 'r.md');
62
+ mkdirSync(join(fakeHome, '.crouter', 'canvas', 'nodes', 'somenode', 'reports'), { recursive: true });
63
+ writeFileSync(reportFile, '# a node report\n');
64
+ const rendered = renderOnReadDocs(node, reportFile, new Set());
65
+ assert.ok(!rendered.includes('PRIVATE ASSISTANT MEMORY MUST NOT LEAK'), `crouter-home-nested store must not surface on a canvas read; got: ${rendered}`);
66
+ assert.ok(!rendered.includes('private-thing'), `crouter-home-nested store name must not surface on a canvas read; got: ${rendered}`);
67
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -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
+ });
@@ -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,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
+ });