@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
package/dist/build-root.js
CHANGED
|
@@ -11,6 +11,7 @@ const SUBTREE_LOADERS = {
|
|
|
11
11
|
memory: async () => (await import('./commands/memory.js')).registerMemory(),
|
|
12
12
|
profile: async () => (await import('./commands/profile.js')).registerProfile(),
|
|
13
13
|
search: async () => (await import('./commands/search.js')).registerSearch(),
|
|
14
|
+
capture: async () => (await import('./commands/capture.js')).registerCapture(),
|
|
14
15
|
pkg: async () => (await import('./commands/pkg.js')).registerPkg(),
|
|
15
16
|
human: async () => (await import('./commands/human.js')).registerHuman(),
|
|
16
17
|
sys: async () => (await import('./commands/sys.js')).registerSys(),
|
|
@@ -17,4 +17,4 @@ You are an implementation agent. Your job is to **implement this feature or chan
|
|
|
17
17
|
|
|
18
18
|
Work directly. Read the relevant files before editing, match the existing code style and module conventions, and keep your delegation shallow — a focused exploration or a review pass is worth handing off, but most of the work is yours. Throw errors early; no silent fallbacks. Break things correctly rather than patching them badly; prefer clean, breaking changes over backwards-compat hacks in pre-production code.
|
|
19
19
|
|
|
20
|
-
Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
|
|
20
|
+
Done means **provably correct against the spec's acceptance criteria** — not "it builds," not "the tests pass." Green output proves the code ran, not that it does what was asked; check the result against each acceptance criterion yourself. On a load-bearing change, get it critiqued by something other than you before calling it done — spawn a reviewer on the diff and fold in what it finds. But validate judiciously: a delegate's green report is settled evidence — don't re-run a suite or re-read a diff that already cleared its gate; check only what changed since. And if the change outgrows what one window can finish well — many files, several phases, a design that keeps moving — promote yourself into a developer orchestrator and decompose it rather than grinding past the edge of your context.
|
|
@@ -12,4 +12,6 @@ Before you shape the roadmap, read `crtr memory read development` for the roadma
|
|
|
12
12
|
|
|
13
13
|
Stay flexible, not waterfall. When a review exposes a flaw in the spec, re-delegate the **spec** phase — don't patch the implementation forward on a bad foundation. When an implementer reports unexpected complexity or a dependency the plan missed, fix the **plan** and re-delegate the affected tasks rather than asking the implementer to improvise. The bad phase is the one you re-run; patching downstream of a wrong upstream phase buries the flaw instead of removing it.
|
|
14
14
|
|
|
15
|
+
Validate judiciously — trust the agent. A gate that already ran green is settled evidence: when a child reports its build clean, its suite passing, or a reviewer reports the diff read, take the report and move on — do not re-run the same suite, rebuild the same tree, or re-read the same code to reassure yourself. Attach each new check to what *changed* since the last green gate (the fix diff, the new tests), never the whole feature from scratch; re-verify a report only when it is internally inconsistent or contradicted by evidence, not on general suspicion. Redundant re-validation burns whole windows and adds no information — the second identical green proves nothing the first didn't.
|
|
16
|
+
|
|
15
17
|
Post-implementation review is not one generic "review this" pass — it is several distinct perspectives, each its own assessment: does this **reuse** what the codebase already provides rather than reinventing it, is the **quality** sound, is it **efficient**, and are the **tests** real rather than green theatre. Hand each lens to its own reviewer so it assesses independently. Size the reviewer to the surface: a small focused diff goes to a single `review` worker; a whole feature's worth of files goes to a `review` orchestrator that fans the lenses across it and returns one verdict.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// attach-chrome-remote.test.ts — the safe-defaults contract for the attach
|
|
2
|
+
// chrome (buildCanvasPanelLines + GraphOverlay) when the `canvasSource` is a
|
|
3
|
+
// RemoteCanvasSource, i.e. `crtr surface attach --canvas <name>`.
|
|
4
|
+
//
|
|
5
|
+
// Adversarial, not merely a clean-machine assertion (mirrors
|
|
6
|
+
// core/canvas/__tests__/render-remote.test.ts): BEFORE rendering, this test
|
|
7
|
+
// seeds real, CONFLICTING local state for node id `a` — a poisoned telemetry
|
|
8
|
+
// reading (context tokens + a live-activity cue), an active fault, a local
|
|
9
|
+
// tmux focus, and a broker attach.json marking it human-watched — every
|
|
10
|
+
// historically-broken local read the remote suppression in nav-model.ts is
|
|
11
|
+
// supposed to bypass. If the production suppression ever regressed back to
|
|
12
|
+
// consulting local disk/SQLite for a remote id, the rendered lines below would
|
|
13
|
+
// carry the poisoned tokens/glyph/background instead of the safe defaults, so
|
|
14
|
+
// a clean-machine run could never mask the regression.
|
|
15
|
+
//
|
|
16
|
+
// Also proves the read-only contract: GraphOverlay's Enter/m/x key handlers
|
|
17
|
+
// must no-op against a remote graph row instead of shelling local
|
|
18
|
+
// `crtr node focus` / `crtr node lifecycle close`.
|
|
19
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { createServer } from 'node:http';
|
|
22
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { RemoteCanvasSource } from '../../../core/canvas/remote-canvas-source.js';
|
|
26
|
+
import { createNode } from '../../../core/canvas/canvas.js';
|
|
27
|
+
import { closeDb } from '../../../core/canvas/db.js';
|
|
28
|
+
import { openFocusRow } from '../../../core/canvas/focuses.js';
|
|
29
|
+
import { jobDir } from '../../../core/canvas/paths.js';
|
|
30
|
+
import { recordFault } from '../../../core/runtime/fault.js';
|
|
31
|
+
import { BG_ATTACHED } from '../../../core/canvas/nav-model.js';
|
|
32
|
+
import { dispatchSlashCommand, slashCommandList } from '../slash-commands.js';
|
|
33
|
+
import { resolveInitialMode, requestModeSwitch, tagAttachPane, untagAttachPane, reapOnDetach, } from '../attach-cmd.js';
|
|
34
|
+
import { buildCanvasPanelLines } from '../canvas-panels.js';
|
|
35
|
+
import { GraphOverlay } from '../graph-overlay.js';
|
|
36
|
+
function startMockCrtrServer(route) {
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const server = createServer((req, res) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
req.on('data', (c) => chunks.push(c));
|
|
41
|
+
req.on('end', () => {
|
|
42
|
+
const bodyText = Buffer.concat(chunks).toString('utf8');
|
|
43
|
+
const parsed = JSON.parse(bodyText);
|
|
44
|
+
const result = route(parsed.args);
|
|
45
|
+
res.writeHead(result.status ?? 200, { 'content-type': 'application/json' });
|
|
46
|
+
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify(result.body), stderr: '' }));
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
server.listen(0, '127.0.0.1', () => {
|
|
50
|
+
const addr = server.address();
|
|
51
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
52
|
+
resolve({
|
|
53
|
+
url: `http://127.0.0.1:${port}`,
|
|
54
|
+
close: () => new Promise((res) => server.close(() => res())),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function meta(id, extra) {
|
|
60
|
+
return {
|
|
61
|
+
node_id: id,
|
|
62
|
+
name: id,
|
|
63
|
+
created: new Date().toISOString(),
|
|
64
|
+
cwd: '/tmp/work',
|
|
65
|
+
kind: 'general',
|
|
66
|
+
mode: 'base',
|
|
67
|
+
lifecycle: 'terminal',
|
|
68
|
+
status: 'active',
|
|
69
|
+
...extra,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function show(node, reports, managers) {
|
|
73
|
+
return {
|
|
74
|
+
node,
|
|
75
|
+
reports: reports.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
|
|
76
|
+
managers: managers.map((id) => ({ node_id: id, name: id, kind: 'general', status: 'active', active: true })),
|
|
77
|
+
artifacts: { report: 0, doc: 0, roadmap: 0 },
|
|
78
|
+
pending_triggers: 0,
|
|
79
|
+
paths: { context_dir: '/tmp/context', reports_dir: '/tmp/reports', session_file: null },
|
|
80
|
+
follow_up: '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const IDENTITY_PALETTE = {
|
|
84
|
+
accent: (s) => s,
|
|
85
|
+
active: (s) => s,
|
|
86
|
+
info: (s) => s,
|
|
87
|
+
muted: (s) => s,
|
|
88
|
+
faint: (s) => s,
|
|
89
|
+
border: (s) => s,
|
|
90
|
+
bold: (s) => s,
|
|
91
|
+
error: (s) => s,
|
|
92
|
+
warning: (s) => s,
|
|
93
|
+
bashMode: (s) => s,
|
|
94
|
+
bashModeAlt: (s) => s,
|
|
95
|
+
surface: (s) => s,
|
|
96
|
+
};
|
|
97
|
+
/** A fake `TUI` carrying just what `GraphOverlay` touches: `showOverlay` (a
|
|
98
|
+
* no-op handle) and `requestRender`. GraphOverlay's key handlers only shell
|
|
99
|
+
* `crtr` (via execFile) and toggle local overlay/handle state — they never
|
|
100
|
+
* reach into the real TUI beyond these two calls. */
|
|
101
|
+
function fakeTui() {
|
|
102
|
+
let renders = 0;
|
|
103
|
+
const tui = {
|
|
104
|
+
showOverlay: () => ({
|
|
105
|
+
hide: () => { },
|
|
106
|
+
setHidden: () => { },
|
|
107
|
+
isHidden: () => false,
|
|
108
|
+
focus: () => { },
|
|
109
|
+
unfocus: () => { },
|
|
110
|
+
isFocused: () => true,
|
|
111
|
+
}),
|
|
112
|
+
requestRender: () => { renders++; },
|
|
113
|
+
};
|
|
114
|
+
return { tui, renderCount: () => renders };
|
|
115
|
+
}
|
|
116
|
+
let home;
|
|
117
|
+
let localCwd;
|
|
118
|
+
before(() => {
|
|
119
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-'));
|
|
120
|
+
localCwd = mkdtempSync(join(tmpdir(), 'crtr-attach-chrome-remote-cwd-'));
|
|
121
|
+
process.env['CRTR_HOME'] = home;
|
|
122
|
+
});
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
closeDb();
|
|
125
|
+
rmSync(home, { recursive: true, force: true });
|
|
126
|
+
});
|
|
127
|
+
after(() => {
|
|
128
|
+
closeDb();
|
|
129
|
+
rmSync(home, { recursive: true, force: true });
|
|
130
|
+
rmSync(localCwd, { recursive: true, force: true });
|
|
131
|
+
delete process.env['CRTR_HOME'];
|
|
132
|
+
});
|
|
133
|
+
/** Seed conflicting local state for node id `a` — every local read the
|
|
134
|
+
* attach-chrome remote suppression is supposed to bypass. */
|
|
135
|
+
function seedPoisonedLocalState() {
|
|
136
|
+
createNode(meta('a', { cwd: localCwd }));
|
|
137
|
+
// A local focus — poisons focusedNodeIds()/isAttached's fast path.
|
|
138
|
+
openFocusRow('poison-focus', null, null, 'a');
|
|
139
|
+
// A broker attach.json — poisons isAttached's slow path.
|
|
140
|
+
writeFileSync(join(jobDir('a'), 'attach.json'), JSON.stringify({ viewers: 1 }));
|
|
141
|
+
// An active, manually-retried fault — poisons nodeGlyph's hanging overlay.
|
|
142
|
+
recordFault('a', {
|
|
143
|
+
link: 'viewer↔broker',
|
|
144
|
+
op: 'poison',
|
|
145
|
+
kind: 'other',
|
|
146
|
+
retry: { disposition: 'manual' },
|
|
147
|
+
message: 'POISON FAULT — local read leaked into remote attach chrome',
|
|
148
|
+
});
|
|
149
|
+
// Local telemetry — poisons tokensCell + activityCell.
|
|
150
|
+
writeFileSync(join(jobDir('a'), 'telemetry.json'), JSON.stringify({
|
|
151
|
+
context_tokens: 999_999,
|
|
152
|
+
last_activity: 'POISON ACTIVITY — local read leaked into remote attach chrome',
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
test('buildCanvasPanelLines suppresses every local-read adornment for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
156
|
+
seedPoisonedLocalState();
|
|
157
|
+
const rootMeta = meta('root');
|
|
158
|
+
const aMeta = meta('a'); // the REMOTE node's meta — unrelated to the poisoned local NodeMeta above
|
|
159
|
+
const server = await startMockCrtrServer((args) => {
|
|
160
|
+
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
|
|
161
|
+
const id = args[4];
|
|
162
|
+
if (id === 'root')
|
|
163
|
+
return { body: show(rootMeta, ['a'], []) };
|
|
164
|
+
if (id === 'a')
|
|
165
|
+
return { body: show(aMeta, [], ['root']) };
|
|
166
|
+
}
|
|
167
|
+
return { status: 500, body: {} };
|
|
168
|
+
});
|
|
169
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
170
|
+
// 'root' has 'a' as a live report → row('a') renders into `reports`.
|
|
171
|
+
const { reports } = await buildCanvasPanelLines('root', {}, IDENTITY_PALETTE, source);
|
|
172
|
+
assert.equal(reports.length, 1);
|
|
173
|
+
const line = reports[0];
|
|
174
|
+
assert.ok(!line.includes('1000k'), `local telemetry (999999 tokens) must not leak into a remote row: ${line}`);
|
|
175
|
+
assert.ok(!line.includes('POISON'), `poisoned local text must never appear in a remote row: ${line}`);
|
|
176
|
+
assert.ok(!line.includes('⚠'), `a local active fault must not surface as the hanging glyph on a remote row: ${line}`);
|
|
177
|
+
assert.ok(!line.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached: ${line}`);
|
|
178
|
+
await server.close();
|
|
179
|
+
});
|
|
180
|
+
test('GraphOverlay suppresses every local-read adornment for a RemoteCanvasSource AND makes Enter/m/x no-ops, even with conflicting local state for the same node id', async () => {
|
|
181
|
+
seedPoisonedLocalState();
|
|
182
|
+
const rootMeta = meta('root');
|
|
183
|
+
const aMeta = meta('a');
|
|
184
|
+
const server = await startMockCrtrServer((args) => {
|
|
185
|
+
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'show') {
|
|
186
|
+
const id = args[4];
|
|
187
|
+
if (id === 'root')
|
|
188
|
+
return { body: show(rootMeta, ['a'], []) };
|
|
189
|
+
if (id === 'a')
|
|
190
|
+
return { body: show(aMeta, [], ['root']) };
|
|
191
|
+
}
|
|
192
|
+
return { status: 500, body: {} };
|
|
193
|
+
});
|
|
194
|
+
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
195
|
+
const { tui } = fakeTui();
|
|
196
|
+
const overlay = new GraphOverlay(tui, 'root', () => ({}), IDENTITY_PALETTE, source);
|
|
197
|
+
overlay.open();
|
|
198
|
+
// buildSnapshot() is async (chased through the mock server); poll for it to land.
|
|
199
|
+
for (let i = 0; i < 100 && overlay.snapshot === undefined; i++) {
|
|
200
|
+
await new Promise((r) => setImmediate(r));
|
|
201
|
+
}
|
|
202
|
+
assert.notEqual(overlay.snapshot, undefined, 'snapshot never built');
|
|
203
|
+
const lines = overlay.render(100).join('\n');
|
|
204
|
+
assert.ok(!lines.includes('1000k'), `local telemetry must not leak into the remote graph overlay: ${lines}`);
|
|
205
|
+
assert.ok(!lines.includes('POISON'), `poisoned local text must never appear in the remote graph overlay: ${lines}`);
|
|
206
|
+
assert.ok(!lines.includes('⚠'), `a local active fault must not surface as the hanging glyph in the remote graph overlay: ${lines}`);
|
|
207
|
+
assert.ok(!lines.includes(BG_ATTACHED), `a local focus/attach must not paint a remote row as attached in the graph overlay: ${lines}`);
|
|
208
|
+
// Footer hint must drop the focus/kill/mgr hints in remote mode.
|
|
209
|
+
assert.ok(!lines.includes('kill'), `remote overlay footer must not advertise kill: ${lines}`);
|
|
210
|
+
assert.ok(!lines.includes('focus') && !lines.includes('mgr'), `remote overlay footer must not advertise focus/mgr: ${lines}`);
|
|
211
|
+
// Enter must NOT close the overlay (a local `close()` is the observable
|
|
212
|
+
// effect of a real `focusTarget` dispatch) — it must no-op instead.
|
|
213
|
+
overlay.handleInput('\r');
|
|
214
|
+
assert.equal(overlay.isOpen(), true, 'Enter must not act (close) on a remote graph row');
|
|
215
|
+
// x must NOT arm the y/n kill confirm — render() would show it in the hint.
|
|
216
|
+
overlay.handleInput('x');
|
|
217
|
+
const afterX = overlay.render(100).join('\n');
|
|
218
|
+
assert.ok(!afterX.includes('y/n'), `x must not arm a kill confirm on a remote graph row: ${afterX}`);
|
|
219
|
+
// m must NOT close the overlay either (it would focusTarget the manager).
|
|
220
|
+
overlay.handleInput('m');
|
|
221
|
+
assert.equal(overlay.isOpen(), true, 'm must not act (close) on a remote graph row');
|
|
222
|
+
await server.close();
|
|
223
|
+
});
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Phase 3 review 2 — the ENCLOSING attach viewer (slash commands, pane tag,
|
|
226
|
+
// detach-reap, Alt+M mode-switch), not just the GraphOverlay/panel rendering
|
|
227
|
+
// the fix above already covers. Same adversarial shape: seed colliding LOCAL
|
|
228
|
+
// state for the remote node id `a`, then assert the remote path never touches
|
|
229
|
+
// it — and, symmetrically, that the LOCAL path is byte-for-byte unchanged
|
|
230
|
+
// (every guard below defaults to acting exactly as before when `remote` is
|
|
231
|
+
// `false`).
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
function fakeSlashContext(remote, nodeId) {
|
|
234
|
+
const notices = [];
|
|
235
|
+
const sent = [];
|
|
236
|
+
const ctx = {
|
|
237
|
+
send: (frame) => sent.push(frame),
|
|
238
|
+
notify: (m) => notices.push(m),
|
|
239
|
+
cwd: '/tmp/does-not-matter',
|
|
240
|
+
// Explicit empty string (not `undefined`) so promoteNode/openContext's
|
|
241
|
+
// `ctx.nodeId ?? process.env['CRTR_NODE_ID']` fallback can NEVER pick up
|
|
242
|
+
// this test process's own real CRTR_NODE_ID (this test runs INSIDE a live
|
|
243
|
+
// crtr node) — the local-mode assertions below must reach the real
|
|
244
|
+
// no-node-id early return, never an accidental real `crtr node promote`
|
|
245
|
+
// against ourselves.
|
|
246
|
+
nodeId: '',
|
|
247
|
+
remote,
|
|
248
|
+
};
|
|
249
|
+
return { ctx, notices, sent };
|
|
250
|
+
}
|
|
251
|
+
test('slash autocomplete structurally omits the local-only native canvas commands (/promote, /resume-node, /context) in remote mode, and keeps them in local mode', () => {
|
|
252
|
+
const remoteNames = slashCommandList(undefined, true).map((c) => c.name);
|
|
253
|
+
assert.ok(!remoteNames.includes('promote'), `remote autocomplete must not offer /promote: ${remoteNames.join(',')}`);
|
|
254
|
+
assert.ok(!remoteNames.includes('resume-node'), `remote autocomplete must not offer /resume-node: ${remoteNames.join(',')}`);
|
|
255
|
+
assert.ok(!remoteNames.includes('context'), `remote autocomplete must not offer /context: ${remoteNames.join(',')}`);
|
|
256
|
+
// Remote-safe canvas commands stay offered.
|
|
257
|
+
assert.ok(remoteNames.includes('graph'), 'remote autocomplete must still offer /graph (already remote-safe)');
|
|
258
|
+
assert.ok(remoteNames.includes('view'), 'remote autocomplete must still offer /view (self-contained popup)');
|
|
259
|
+
// Local (remote=false, the default) is unchanged — all five canvas commands.
|
|
260
|
+
const localNames = slashCommandList().map((c) => c.name);
|
|
261
|
+
for (const name of ['graph', 'promote', 'resume-node', 'view', 'context']) {
|
|
262
|
+
assert.ok(localNames.includes(name), `local autocomplete must still offer /${name}: ${localNames.join(',')}`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
test('dispatchSlashCommand no-ops /promote, /resume-node, /context in remote mode instead of shelling a local crtr command', () => {
|
|
266
|
+
const { ctx, notices, sent } = fakeSlashContext(true, 'a');
|
|
267
|
+
for (const cmd of ['/promote', '/resume-node', '/context', '/promote orchestrator']) {
|
|
268
|
+
notices.length = 0;
|
|
269
|
+
const handled = dispatchSlashCommand(cmd, ctx);
|
|
270
|
+
assert.equal(handled, true, `${cmd} must be handled (native, never forwarded to the engine as a prompt)`);
|
|
271
|
+
assert.equal(notices.length, 1, `${cmd} must notify exactly once (the remote-unsafe notice), got: ${JSON.stringify(notices)}`);
|
|
272
|
+
assert.match(notices[0], /not available for a remote attach/, `${cmd} must notify the remote-unsafe message, not proceed into its local handler: ${notices[0]}`);
|
|
273
|
+
}
|
|
274
|
+
assert.equal(sent.length, 0, 'no command frame must ever be sent for a gated remote canvas command');
|
|
275
|
+
// /graph and /view are UNaffected by the remote gate (graph is already
|
|
276
|
+
// remote-safe via GraphOverlay's own `remote` handling; view is self-
|
|
277
|
+
// contained). Neither reaches the remote-unsafe notice.
|
|
278
|
+
notices.length = 0;
|
|
279
|
+
ctx.onGraph = () => notices.push('graph-toggled');
|
|
280
|
+
dispatchSlashCommand('/graph', ctx);
|
|
281
|
+
assert.deepEqual(notices, ['graph-toggled'], '/graph must still work normally in remote mode');
|
|
282
|
+
});
|
|
283
|
+
test('dispatchSlashCommand leaves the LOCAL (non-remote) path for /promote, /resume-node, /context byte-for-byte unchanged', () => {
|
|
284
|
+
const { ctx, notices } = fakeSlashContext(false, '');
|
|
285
|
+
// No TMUX in this test process (a headless `node --test` run), so
|
|
286
|
+
// /resume-node and /context hit their OWN "needs tmux" notices — proving
|
|
287
|
+
// dispatch reached their real local handlers, not our new remote gate.
|
|
288
|
+
const savedTmux = process.env['TMUX'];
|
|
289
|
+
const savedTmuxPane = process.env['TMUX_PANE'];
|
|
290
|
+
delete process.env['TMUX'];
|
|
291
|
+
delete process.env['TMUX_PANE'];
|
|
292
|
+
try {
|
|
293
|
+
notices.length = 0;
|
|
294
|
+
dispatchSlashCommand('/promote', ctx);
|
|
295
|
+
assert.deepEqual(notices, ['/promote: no node to promote (viewer has no node id)']);
|
|
296
|
+
notices.length = 0;
|
|
297
|
+
dispatchSlashCommand('/resume-node', ctx);
|
|
298
|
+
assert.deepEqual(notices, ['/resume-node needs tmux']);
|
|
299
|
+
notices.length = 0;
|
|
300
|
+
dispatchSlashCommand('/context', ctx);
|
|
301
|
+
assert.deepEqual(notices, ['/context needs tmux']);
|
|
302
|
+
}
|
|
303
|
+
finally {
|
|
304
|
+
if (savedTmux !== undefined)
|
|
305
|
+
process.env['TMUX'] = savedTmux;
|
|
306
|
+
else
|
|
307
|
+
delete process.env['TMUX'];
|
|
308
|
+
if (savedTmuxPane !== undefined)
|
|
309
|
+
process.env['TMUX_PANE'] = savedTmuxPane;
|
|
310
|
+
else
|
|
311
|
+
delete process.env['TMUX_PANE'];
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
test('resolveInitialMode/requestModeSwitch never touch the local mode-switch read/write for a remote attach, and are unchanged for local', () => {
|
|
315
|
+
let reads = 0;
|
|
316
|
+
const readMode = (_cwd, _nodeId) => {
|
|
317
|
+
reads++;
|
|
318
|
+
return 'plan'; // poisoned value — must never surface for remote
|
|
319
|
+
};
|
|
320
|
+
assert.equal(resolveInitialMode('/tmp/cwd', 'a', true, readMode), 'normal', 'remote must always start normal');
|
|
321
|
+
assert.equal(reads, 0, 'remote must never read the local mode-switch current file');
|
|
322
|
+
assert.equal(resolveInitialMode('/tmp/cwd', 'a', false, readMode), 'plan', 'local must read through unchanged');
|
|
323
|
+
assert.equal(reads, 1, 'local must call the injected local reader exactly once');
|
|
324
|
+
let writes = 0;
|
|
325
|
+
let lastWrite;
|
|
326
|
+
const writeRequest = (cwd, nodeId, mode) => {
|
|
327
|
+
writes++;
|
|
328
|
+
lastWrite = { cwd, nodeId, mode };
|
|
329
|
+
};
|
|
330
|
+
const remoteOk = requestModeSwitch('/tmp/cwd', 'a', true, 'spec', writeRequest);
|
|
331
|
+
assert.equal(remoteOk, false, 'remote Alt+M must no-op (return false)');
|
|
332
|
+
assert.equal(writes, 0, 'remote must never write the local mode-switch request file');
|
|
333
|
+
const localOk = requestModeSwitch('/tmp/cwd', 'a', false, 'spec', writeRequest);
|
|
334
|
+
assert.equal(localOk, true, 'local Alt+M must still write through');
|
|
335
|
+
assert.equal(writes, 1, 'local must call the injected local writer exactly once');
|
|
336
|
+
assert.deepEqual(lastWrite, { cwd: '/tmp/cwd', nodeId: 'a', mode: 'spec' });
|
|
337
|
+
});
|
|
338
|
+
test('tagAttachPane/untagAttachPane never tag/untag the pane for a remote attach, and are unchanged for local', () => {
|
|
339
|
+
let calls = [];
|
|
340
|
+
const setOpt = (pane, name, value) => {
|
|
341
|
+
calls.push([pane, name, value]);
|
|
342
|
+
};
|
|
343
|
+
calls = [];
|
|
344
|
+
tagAttachPane(true, '%3', 'a', setOpt);
|
|
345
|
+
assert.deepEqual(calls, [], 'remote must never tag the pane with @crtr_node');
|
|
346
|
+
calls = [];
|
|
347
|
+
tagAttachPane(false, '%3', 'a', setOpt);
|
|
348
|
+
assert.deepEqual(calls, [['%3', '@crtr_node', 'a']], 'local tag must still fire unchanged');
|
|
349
|
+
calls = [];
|
|
350
|
+
untagAttachPane(true, '%3', setOpt);
|
|
351
|
+
assert.deepEqual(calls, [], 'remote must never untag the pane');
|
|
352
|
+
calls = [];
|
|
353
|
+
untagAttachPane(false, '%3', setOpt);
|
|
354
|
+
assert.deepEqual(calls, [['%3', '@crtr_node', '']], 'local untag must still fire unchanged');
|
|
355
|
+
// No pane resolved (outside tmux) — unaffected either way.
|
|
356
|
+
calls = [];
|
|
357
|
+
tagAttachPane(false, undefined, 'a', setOpt);
|
|
358
|
+
assert.deepEqual(calls, [], 'no pane to tag when outside tmux');
|
|
359
|
+
});
|
|
360
|
+
test('reapOnDetach never reaps a local node for a remote attach even on an id collision, and is unchanged for local', () => {
|
|
361
|
+
let reaped = [];
|
|
362
|
+
const reap = (nodeId) => {
|
|
363
|
+
reaped.push(nodeId);
|
|
364
|
+
};
|
|
365
|
+
reaped = [];
|
|
366
|
+
reapOnDetach(true, 'a', reap);
|
|
367
|
+
assert.deepEqual(reaped, [], 'remote detach must never reap a local node, even one colliding with the remote id');
|
|
368
|
+
reaped = [];
|
|
369
|
+
reapOnDetach(false, 'a', reap);
|
|
370
|
+
assert.deepEqual(reaped, ['a'], 'local detach-reap must still fire unchanged');
|
|
371
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// attach-remote-readonly.test.ts — Phase 3 review Major 1: a REMOTE attach
|
|
2
|
+
// (`crtr surface attach --canvas <name>`) must be a strictly VIEW-ONLY
|
|
3
|
+
// surface. Three defenses are proven independently here:
|
|
4
|
+
//
|
|
5
|
+
// A. slash-commands.ts's REMOTE_SAFE_BUILTIN_NAMES gate — a remote attach
|
|
6
|
+
// never even dispatches a mutating builtin slash command.
|
|
7
|
+
// B. input-controller.ts's central `emitCommand` choke point — even if a
|
|
8
|
+
// caller somehow got past A, the REAL InputController (constructed
|
|
9
|
+
// against fakes, not stubbed out) never lets a drive frame reach
|
|
10
|
+
// `hooks.onCommand`, for every input path (submit, `!bash`, Esc-abort,
|
|
11
|
+
// and every direct keybinding).
|
|
12
|
+
// C. attach-cmd.ts's `resolveHelloRole` — the hello handshake always
|
|
13
|
+
// claims 'observer' for a remote attach, regardless of `--observer`.
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { dispatchSlashCommand } from '../slash-commands.js';
|
|
17
|
+
import { InputController } from '../input-controller.js';
|
|
18
|
+
import { resolveHelloRole } from '../attach-cmd.js';
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Half A — slash-command gate
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function fakeSlashContext(remote, nodeId) {
|
|
23
|
+
const notices = [];
|
|
24
|
+
const sent = [];
|
|
25
|
+
const ctx = {
|
|
26
|
+
send: (frame) => sent.push(frame),
|
|
27
|
+
notify: (m) => notices.push(m),
|
|
28
|
+
cwd: '/tmp/does-not-matter',
|
|
29
|
+
nodeId,
|
|
30
|
+
remote,
|
|
31
|
+
};
|
|
32
|
+
return { ctx, notices, sent };
|
|
33
|
+
}
|
|
34
|
+
const MUTATING_BUILTINS = [
|
|
35
|
+
'/new', '/compact', '/name test', '/model', '/resume', '/fork', '/tree',
|
|
36
|
+
'/settings', '/share', '/clone', '/import', '/reload', '/scoped-models',
|
|
37
|
+
'/login', '/logout', '/export',
|
|
38
|
+
];
|
|
39
|
+
test('dispatchSlashCommand blocks every mutating/wrong-host builtin in remote mode, never sending a frame', () => {
|
|
40
|
+
const { ctx, notices, sent } = fakeSlashContext(true, 'a');
|
|
41
|
+
for (const cmd of MUTATING_BUILTINS) {
|
|
42
|
+
notices.length = 0;
|
|
43
|
+
const handled = dispatchSlashCommand(cmd, ctx);
|
|
44
|
+
assert.equal(handled, true, `${cmd} must be handled (blocked, never forwarded to the engine as a prompt)`);
|
|
45
|
+
assert.equal(notices.length, 1, `${cmd} must notify exactly once, got: ${JSON.stringify(notices)}`);
|
|
46
|
+
assert.match(notices[0], /not available for a remote attach/, `${cmd} must produce the remote-block notice, got: ${notices[0]}`);
|
|
47
|
+
}
|
|
48
|
+
assert.equal(sent.length, 0, 'no command frame must ever reach ctx.send for a blocked remote builtin');
|
|
49
|
+
});
|
|
50
|
+
test('the same builtins dispatch normally in local mode (remote unaffected proof)', () => {
|
|
51
|
+
const { ctx, sent } = fakeSlashContext(false, '');
|
|
52
|
+
assert.equal(dispatchSlashCommand('/name a-new-name', ctx), true);
|
|
53
|
+
assert.equal(dispatchSlashCommand('/new', ctx), true);
|
|
54
|
+
const types = sent.map((f) => f.type);
|
|
55
|
+
assert.ok(types.includes('set_session_name'), `expected set_session_name in local mode, got ${JSON.stringify(types)}`);
|
|
56
|
+
assert.ok(types.includes('new_session'), `expected new_session in local mode, got ${JSON.stringify(types)}`);
|
|
57
|
+
});
|
|
58
|
+
test('/copy /session /changelog /hotkeys /quit remain reachable in remote mode (not blocked by the gate)', () => {
|
|
59
|
+
const { ctx, notices } = fakeSlashContext(true, 'a');
|
|
60
|
+
notices.length = 0;
|
|
61
|
+
ctx.state = { model: 'test-model' };
|
|
62
|
+
dispatchSlashCommand('/session', ctx);
|
|
63
|
+
assert.equal(notices.length, 1);
|
|
64
|
+
assert.ok(!/not available for a remote attach/.test(notices[0]), `/session must not hit the remote-block notice, got: ${notices[0]}`);
|
|
65
|
+
notices.length = 0;
|
|
66
|
+
dispatchSlashCommand('/changelog', ctx);
|
|
67
|
+
assert.equal(notices.length, 1);
|
|
68
|
+
assert.ok(!/not available for a remote attach/.test(notices[0]));
|
|
69
|
+
notices.length = 0;
|
|
70
|
+
dispatchSlashCommand('/hotkeys', ctx);
|
|
71
|
+
assert.equal(notices.length, 1);
|
|
72
|
+
assert.ok(!/not available for a remote attach/.test(notices[0]));
|
|
73
|
+
notices.length = 0;
|
|
74
|
+
let copied = false;
|
|
75
|
+
ctx.onCopy = () => { copied = true; };
|
|
76
|
+
dispatchSlashCommand('/copy', ctx);
|
|
77
|
+
assert.equal(copied, true, '/copy must reach its onCopy hook in remote mode');
|
|
78
|
+
notices.length = 0;
|
|
79
|
+
let quit = false;
|
|
80
|
+
ctx.onQuit = () => { quit = true; };
|
|
81
|
+
dispatchSlashCommand('/quit', ctx);
|
|
82
|
+
assert.equal(quit, true, '/quit must reach its onQuit hook in remote mode');
|
|
83
|
+
});
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Half B — InputController's central emitCommand gate, against a REAL
|
|
86
|
+
// InputController built from minimal fakes.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
function fakeEditor() {
|
|
89
|
+
const actions = new Map();
|
|
90
|
+
let text = '';
|
|
91
|
+
const history = [];
|
|
92
|
+
const obj = {
|
|
93
|
+
onSubmit: undefined,
|
|
94
|
+
onEscape: undefined,
|
|
95
|
+
onPasteImage: undefined,
|
|
96
|
+
onAction: (name, cb) => { actions.set(name, cb); },
|
|
97
|
+
getText: () => text,
|
|
98
|
+
setText: (t) => { text = t; },
|
|
99
|
+
addToHistory: (t) => { history.push(t); },
|
|
100
|
+
insertTextAtCursor: (t) => { text += t; },
|
|
101
|
+
};
|
|
102
|
+
return { obj, actions, setText: (t) => { text = t; } };
|
|
103
|
+
}
|
|
104
|
+
function fakeTui() {
|
|
105
|
+
return {
|
|
106
|
+
requestRender: () => { },
|
|
107
|
+
setFocus: () => { },
|
|
108
|
+
showOverlay: () => ({ hide: () => { } }),
|
|
109
|
+
start: () => { },
|
|
110
|
+
stop: () => { },
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function buildController(remote) {
|
|
114
|
+
const sent = [];
|
|
115
|
+
const notices = [];
|
|
116
|
+
const editor = fakeEditor();
|
|
117
|
+
const tui = fakeTui();
|
|
118
|
+
const ic = new InputController(tui, editor.obj, {}, {
|
|
119
|
+
onCommand: (f) => sent.push(f),
|
|
120
|
+
onDialogResponse: () => { },
|
|
121
|
+
onNotice: (m) => notices.push(m),
|
|
122
|
+
remote,
|
|
123
|
+
});
|
|
124
|
+
return { ic, editor, sent, notices };
|
|
125
|
+
}
|
|
126
|
+
test('InputController never emits a drive frame to onCommand when remote is true — every input path', () => {
|
|
127
|
+
const { ic, editor, sent, notices } = buildController(true);
|
|
128
|
+
// 1. Text submission → would have been `prompt`.
|
|
129
|
+
editor.setText('');
|
|
130
|
+
editor.obj.onSubmit('hello');
|
|
131
|
+
assert.equal(sent.length, 0);
|
|
132
|
+
// 2. `!` bash → would have been `bash`.
|
|
133
|
+
editor.setText('');
|
|
134
|
+
editor.obj.onSubmit('!ls');
|
|
135
|
+
assert.equal(sent.length, 0);
|
|
136
|
+
// 3. Esc while streaming → would have been `abort`.
|
|
137
|
+
ic.setState({ isStreaming: true });
|
|
138
|
+
editor.obj.onEscape();
|
|
139
|
+
assert.equal(sent.length, 0);
|
|
140
|
+
// 4. Direct keybindings → new_session, cycle_model×2, cycle_thinking.
|
|
141
|
+
editor.actions.get('app.session.new')();
|
|
142
|
+
editor.actions.get('app.model.cycleForward')();
|
|
143
|
+
editor.actions.get('app.model.cycleBackward')();
|
|
144
|
+
editor.actions.get('app.thinking.cycle')();
|
|
145
|
+
assert.equal(sent.length, 0, `remote InputController must never emit a frame, got: ${JSON.stringify(sent)}`);
|
|
146
|
+
// 5. The gate must have actually fired (proves the hooks weren't simply
|
|
147
|
+
// never wired — a real block happened).
|
|
148
|
+
assert.ok(notices.some((n) => /Read-only remote attach/.test(n)), `expected a "Read-only remote attach" notice, got: ${JSON.stringify(notices)}`);
|
|
149
|
+
// 6. `app.message.dequeue` → handleDequeue's own remote guard (a second
|
|
150
|
+
// frame type, `dequeue`, not covered by the direct-keybinding block above).
|
|
151
|
+
notices.length = 0;
|
|
152
|
+
editor.actions.get('app.message.dequeue')();
|
|
153
|
+
assert.equal(sent.length, 0, `remote dequeue must never emit a frame, got: ${JSON.stringify(sent)}`);
|
|
154
|
+
assert.ok(notices.some((n) => /Read-only remote attach/.test(n)), `expected a "Read-only remote attach" notice from dequeue, got: ${JSON.stringify(notices)}`);
|
|
155
|
+
});
|
|
156
|
+
test('InputController emits the SAME drive frames normally when remote is false (local unaffected proof)', () => {
|
|
157
|
+
const { ic, editor, sent, notices } = buildController(false);
|
|
158
|
+
editor.setText('');
|
|
159
|
+
editor.obj.onSubmit('hello');
|
|
160
|
+
editor.setText('');
|
|
161
|
+
editor.obj.onSubmit('!ls');
|
|
162
|
+
ic.setState({ isStreaming: true });
|
|
163
|
+
editor.obj.onEscape();
|
|
164
|
+
editor.actions.get('app.session.new')();
|
|
165
|
+
editor.actions.get('app.model.cycleForward')();
|
|
166
|
+
editor.actions.get('app.model.cycleBackward')();
|
|
167
|
+
editor.actions.get('app.thinking.cycle')();
|
|
168
|
+
// `app.message.dequeue` locally never hits the remote guard (it falls
|
|
169
|
+
// through to the "not available in this viewer" branch since the fake
|
|
170
|
+
// hooks supply no `onRequest`) — proves handleDequeue's remote check is
|
|
171
|
+
// remote-conditional, not an unconditional block.
|
|
172
|
+
notices.length = 0;
|
|
173
|
+
editor.actions.get('app.message.dequeue')();
|
|
174
|
+
assert.ok(!notices.some((n) => /Read-only remote attach/.test(n)), `local dequeue must not hit the read-only remote notice, got: ${JSON.stringify(notices)}`);
|
|
175
|
+
const types = sent.map((f) => f.type);
|
|
176
|
+
assert.ok(types.includes('prompt'), `expected prompt, got ${JSON.stringify(types)}`);
|
|
177
|
+
assert.ok(types.includes('bash'), `expected bash, got ${JSON.stringify(types)}`);
|
|
178
|
+
assert.ok(types.includes('abort'), `expected abort, got ${JSON.stringify(types)}`);
|
|
179
|
+
assert.ok(types.includes('new_session'), `expected new_session, got ${JSON.stringify(types)}`);
|
|
180
|
+
assert.equal(types.filter((t) => t === 'cycle_model').length, 2, `expected two cycle_model frames, got ${JSON.stringify(types)}`);
|
|
181
|
+
assert.ok(types.includes('cycle_thinking'), `expected cycle_thinking, got ${JSON.stringify(types)}`);
|
|
182
|
+
});
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Half C — hello role
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
test('resolveHelloRole always claims observer for remote, regardless of --observer', () => {
|
|
187
|
+
assert.equal(resolveHelloRole(true, false), 'observer');
|
|
188
|
+
assert.equal(resolveHelloRole(true, true), 'observer');
|
|
189
|
+
assert.equal(resolveHelloRole(false, false), 'controller');
|
|
190
|
+
assert.equal(resolveHelloRole(false, true), 'observer');
|
|
191
|
+
});
|
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
// NOT-gone counterparts), and a non-101 handshake response (bad token) not
|
|
8
8
|
// hanging connect().
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
-
import test from 'node:test';
|
|
10
|
+
import test, { after, before } from 'node:test';
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
11
14
|
import { WebSocketServer } from 'ws';
|
|
12
15
|
import { RelayTransport } from '../transport-relay.js';
|
|
13
16
|
import { BrokerUnavailableError } from '../transport.js';
|
|
17
|
+
import { jobDir } from '../../../core/canvas/paths.js';
|
|
14
18
|
/** Stand up a bare `ws` server on an ephemeral loopback port — the "relay" a
|
|
15
19
|
* RelayTransport dials. Records the Authorization header + accepted sockets
|
|
16
20
|
* of every connection so a test can assert on the wire, not just behavior. */
|
|
@@ -153,6 +157,39 @@ test('an immediate post-upgrade 1011 "no node <id>" close is still caught by a l
|
|
|
153
157
|
transport.destroy();
|
|
154
158
|
await new Promise((res) => wss.close(() => res()));
|
|
155
159
|
});
|
|
160
|
+
// --- Phase 3 review Major 3: RelayTransport must never write LOCAL fault
|
|
161
|
+
// state keyed by a REMOTE node id (it is only ever constructed for --canvas
|
|
162
|
+
// attach; there is no legitimate local use case). ---
|
|
163
|
+
let faultTestHome;
|
|
164
|
+
before(() => {
|
|
165
|
+
faultTestHome = mkdtempSync(join(tmpdir(), 'crtr-transport-relay-fault-'));
|
|
166
|
+
process.env['CRTR_HOME'] = faultTestHome;
|
|
167
|
+
});
|
|
168
|
+
after(() => {
|
|
169
|
+
rmSync(faultTestHome, { recursive: true, force: true });
|
|
170
|
+
delete process.env['CRTR_HOME'];
|
|
171
|
+
});
|
|
172
|
+
test('RelayTransport never writes local fault state for the remote node id, even on a connect failure', async () => {
|
|
173
|
+
const nodeId = 'remote-fault-test-node';
|
|
174
|
+
const wss = new WebSocketServer({
|
|
175
|
+
port: 0,
|
|
176
|
+
verifyClient: (_info, cb) => cb(false, 401, 'unauthorized'),
|
|
177
|
+
});
|
|
178
|
+
await new Promise((resolve) => wss.once('listening', resolve));
|
|
179
|
+
const addr = wss.address();
|
|
180
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
181
|
+
const transport = new RelayTransport(nodeId, {
|
|
182
|
+
previewEndpoint: `http://127.0.0.1:${port}`,
|
|
183
|
+
relayToken: 'bad-token',
|
|
184
|
+
});
|
|
185
|
+
await new Promise((resolve) => {
|
|
186
|
+
transport.once('error', () => resolve());
|
|
187
|
+
transport.connect();
|
|
188
|
+
});
|
|
189
|
+
assert.equal(existsSync(join(jobDir(nodeId), 'fault')), false, 'a remote relay connect failure must never write a LOCAL fault file keyed by the remote node id');
|
|
190
|
+
transport.destroy();
|
|
191
|
+
await new Promise((res) => wss.close(() => res()));
|
|
192
|
+
});
|
|
156
193
|
test('a non-101 handshake response (401, bad token) rejects connect() with a clear error, not a hang', async () => {
|
|
157
194
|
const wss = new WebSocketServer({
|
|
158
195
|
port: 0,
|