@crouton-kit/crouter 0.3.66 → 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 (72) 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/clients/attach/__tests__/attach-chrome-remote.test.d.ts +1 -0
  4. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +371 -0
  5. package/dist/clients/attach/__tests__/attach-remote-readonly.test.d.ts +1 -0
  6. package/dist/clients/attach/__tests__/attach-remote-readonly.test.js +191 -0
  7. package/dist/clients/attach/__tests__/transport-relay.test.js +38 -1
  8. package/dist/clients/attach/attach-cmd.d.ts +30 -2
  9. package/dist/clients/attach/attach-cmd.js +622 -622
  10. package/dist/clients/attach/canvas-panels.js +9 -3
  11. package/dist/clients/attach/graph-overlay.d.ts +5 -0
  12. package/dist/clients/attach/graph-overlay.js +40 -7
  13. package/dist/clients/attach/input-controller.d.ts +9 -0
  14. package/dist/clients/attach/input-controller.js +47 -8
  15. package/dist/clients/attach/slash-commands.d.ts +11 -4
  16. package/dist/clients/attach/slash-commands.js +44 -9
  17. package/dist/clients/attach/transport-relay.d.ts +5 -5
  18. package/dist/clients/attach/transport-relay.js +3 -30
  19. package/dist/commands/__tests__/canvas-config.test.d.ts +1 -0
  20. package/dist/commands/__tests__/canvas-config.test.js +107 -0
  21. package/dist/commands/canvas-browse.js +26 -1
  22. package/dist/commands/canvas-config.d.ts +2 -0
  23. package/dist/commands/canvas-config.js +210 -0
  24. package/dist/commands/canvas-use.d.ts +1 -0
  25. package/dist/commands/canvas-use.js +63 -0
  26. package/dist/commands/canvas.js +4 -2
  27. package/dist/commands/node.js +41 -11
  28. package/dist/core/__tests__/remote-canvas-target.test.d.ts +1 -0
  29. package/dist/core/__tests__/remote-canvas-target.test.js +87 -0
  30. package/dist/core/__tests__/secrets.test.d.ts +1 -0
  31. package/dist/core/__tests__/secrets.test.js +55 -0
  32. package/dist/core/canvas/__tests__/remote-canvas-source.test.d.ts +1 -0
  33. package/dist/core/canvas/__tests__/remote-canvas-source.test.js +371 -0
  34. package/dist/core/canvas/__tests__/remote-event-stream.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/remote-event-stream.test.js +144 -0
  36. package/dist/core/canvas/__tests__/render-remote.test.d.ts +1 -0
  37. package/dist/core/canvas/__tests__/render-remote.test.js +221 -0
  38. package/dist/core/canvas/__tests__/source-resolve.test.d.ts +1 -0
  39. package/dist/core/canvas/__tests__/source-resolve.test.js +77 -0
  40. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.d.ts +1 -0
  41. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +117 -0
  42. package/dist/core/canvas/browse/app.d.ts +14 -0
  43. package/dist/core/canvas/browse/app.js +97 -15
  44. package/dist/core/canvas/browse/rebuild-coalescer.d.ts +7 -0
  45. package/dist/core/canvas/browse/rebuild-coalescer.js +61 -0
  46. package/dist/core/canvas/browse/render.d.ts +4 -0
  47. package/dist/core/canvas/browse/render.js +3 -1
  48. package/dist/core/canvas/nav-model.d.ts +19 -10
  49. package/dist/core/canvas/nav-model.js +30 -12
  50. package/dist/core/canvas/remote-canvas-source.d.ts +62 -0
  51. package/dist/core/canvas/remote-canvas-source.js +222 -0
  52. package/dist/core/canvas/remote-event-stream.d.ts +24 -0
  53. package/dist/core/canvas/remote-event-stream.js +94 -0
  54. package/dist/core/canvas/render.d.ts +13 -1
  55. package/dist/core/canvas/render.js +56 -37
  56. package/dist/core/canvas/source.d.ts +9 -0
  57. package/dist/core/canvas/source.js +15 -0
  58. package/dist/core/config.js +4 -3
  59. package/dist/core/runtime/__tests__/node-env.test.d.ts +1 -0
  60. package/dist/core/runtime/__tests__/node-env.test.js +91 -0
  61. package/dist/core/runtime/nodes.js +10 -0
  62. package/dist/core/secrets.d.ts +25 -0
  63. package/dist/core/secrets.js +55 -0
  64. package/dist/core/view/__tests__/transport-remote.test.d.ts +1 -0
  65. package/dist/core/view/__tests__/transport-remote.test.js +95 -0
  66. package/dist/core/view/remote-canvas-target.d.ts +2 -1
  67. package/dist/core/view/remote-canvas-target.js +12 -7
  68. package/dist/core/view/transport-remote.d.ts +2 -0
  69. package/dist/core/view/transport-remote.js +53 -0
  70. package/dist/types.d.ts +19 -11
  71. package/dist/types.js +1 -1
  72. 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
+ });
@@ -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
+ });
@@ -0,0 +1,144 @@
1
+ // remote-event-stream.test.ts — `RemoteEventStream` (`../remote-event-stream.ts`),
2
+ // a manual SSE client over `/__crtr/events`. Covers the wire parse (only
3
+ // known `kind`s fire listeners; comments/pings/unknown kinds are silently
4
+ // ignored) and that `stop()` actually halts the reconnect loop rather than
5
+ // leaving it retrying forever in the background.
6
+ //
7
+ // Reconnect/backoff timing uses an INJECTABLE backoff (`RemoteEventStreamOptions`)
8
+ // so these tests run against a small, fast backoff window instead of the
9
+ // production 1s→30s one, and every wait is a condition-poll on an observed
10
+ // request/event count (`waitFor`) rather than a fixed wall-clock sleep —
11
+ // less flaky under scheduler variance, and fast.
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { createServer } from 'node:http';
15
+ import { RemoteEventStream } from '../remote-event-stream.js';
16
+ function startSseServer(onRequest) {
17
+ return new Promise((resolve) => {
18
+ let hits = 0;
19
+ const requests = [];
20
+ const server = createServer((req, res) => {
21
+ hits += 1;
22
+ requests.push({ method: req.method, url: req.url ?? '', headers: req.headers });
23
+ onRequest(res, req);
24
+ });
25
+ server.listen(0, '127.0.0.1', () => {
26
+ const addr = server.address();
27
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
28
+ resolve({
29
+ url: `http://127.0.0.1:${port}`,
30
+ hitCount: () => hits,
31
+ requests,
32
+ close: () => new Promise((res) => server.close(() => res())),
33
+ });
34
+ });
35
+ });
36
+ }
37
+ /** Poll `predicate` until it's true, or throw after `timeoutMs` — used in
38
+ * place of a fixed sleep so tests assert on the OBSERVED condition (a
39
+ * request landed, an event fired) rather than a guessed timing window. */
40
+ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
41
+ const deadline = Date.now() + timeoutMs;
42
+ while (!predicate()) {
43
+ if (Date.now() > deadline)
44
+ throw new Error(`waitFor: condition not met within ${timeoutMs}ms`);
45
+ await new Promise((r) => setTimeout(r, intervalMs));
46
+ }
47
+ }
48
+ test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
49
+ const server = await startSseServer((res) => {
50
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
51
+ res.write(': connected\n\n');
52
+ res.write('data: {"kind":"nodes"}\n\n');
53
+ res.write(': ping\n\n');
54
+ res.write('data: {"kind":"bogus"}\n\n');
55
+ res.end();
56
+ });
57
+ const stream = new RemoteEventStream(server.url, 'tok');
58
+ const seen = [];
59
+ stream.subscribe((kind) => seen.push(kind));
60
+ stream.start();
61
+ await waitFor(() => seen.length > 0);
62
+ stream.stop();
63
+ assert.deepEqual(seen, ['nodes']);
64
+ await server.close();
65
+ });
66
+ test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
67
+ const server = await startSseServer((res) => {
68
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
69
+ res.end();
70
+ });
71
+ // A token that cannot collide with a hostname/port (unlike 'tok', which is a
72
+ // substring of numeric-looking noise) so a pass here can't be a coincidence —
73
+ // no header, including Host, is permitted to carry it.
74
+ const token = 'secret-sse-token-value';
75
+ const stream = new RemoteEventStream(server.url, token);
76
+ stream.start();
77
+ await waitFor(() => server.requests.length > 0);
78
+ stream.stop();
79
+ assert.equal(server.requests.length, 1);
80
+ const req = server.requests[0];
81
+ assert.equal(req.method, 'GET');
82
+ assert.equal(req.url, '/__crtr/events');
83
+ assert.equal(req.headers['authorization'], `Bearer ${token}`);
84
+ for (const [k, v] of Object.entries(req.headers)) {
85
+ if (k.toLowerCase() === 'authorization')
86
+ continue;
87
+ assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
88
+ }
89
+ assert.ok(!req.url.includes(token));
90
+ await server.close();
91
+ });
92
+ test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
93
+ const server = await startSseServer((res) => {
94
+ res.writeHead(500);
95
+ res.end();
96
+ });
97
+ // A small injected backoff (200ms, not production's 1s) — the test asserts
98
+ // the SAME relative-timing invariant (a stopped loop's dangling reconnect
99
+ // timer must not fire) but the deliberate gap below (half the backoff)
100
+ // between the stale deadline and the new deadline is kept proportionally
101
+ // wide enough that ordinary event-loop/scheduler jitter can't flip it.
102
+ const BACKOFF = 200;
103
+ const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
104
+ stream.start();
105
+ // t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
106
+ await waitFor(() => server.hitCount() >= 1);
107
+ assert.equal(server.hitCount(), 1);
108
+ // Deliberate mid-backoff gap (half the window) before stop()+start(), so the
109
+ // stale deadline (hit1 + BACKOFF) and the new loop's own deadline
110
+ // (hit2 + BACKOFF) land ~BACKOFF/2 apart — the window this test inspects.
111
+ await new Promise((r) => setTimeout(r, BACKOFF / 2));
112
+ stream.stop();
113
+ stream.start();
114
+ await waitFor(() => server.hitCount() >= 2);
115
+ assert.equal(server.hitCount(), 2, 'restart fires its own immediate request');
116
+ // Wait past the STALE timer's deadline (~BACKOFF from the first request) but
117
+ // well before the NEW loop's own deadline (~BACKOFF from the second,
118
+ // later, request). A dangling old-loop timer (bug) would add a 3rd hit in
119
+ // this window; a cleared one (fixed) leaves it at 2.
120
+ await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
121
+ assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
122
+ stream.stop();
123
+ await server.close();
124
+ });
125
+ test('stop() halts the reconnect loop — no further requests after it is called', async () => {
126
+ const server = await startSseServer((res) => {
127
+ // Every connection is refused immediately (no SSE headers, just a close)
128
+ // so the client's loop falls into its backoff-and-retry path.
129
+ res.writeHead(500);
130
+ res.end();
131
+ });
132
+ const BACKOFF = 20;
133
+ const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
134
+ stream.start();
135
+ await waitFor(() => server.hitCount() >= 1);
136
+ const hitsAtStop = server.hitCount();
137
+ stream.stop();
138
+ // The reconnect backoff is BACKOFF ms; wait well past it and confirm no new
139
+ // connection was attempted — proves the loop actually stopped rather than
140
+ // continuing to retry in the background after stop() returned.
141
+ await new Promise((r) => setTimeout(r, BACKOFF * 5));
142
+ assert.equal(server.hitCount(), hitsAtStop);
143
+ await server.close();
144
+ });