@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,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,
@@ -1,7 +1,35 @@
1
1
  import { CombinedAutocompleteProvider } from '@earendil-works/pi-tui';
2
2
  import type { BranchDef } from '../../core/command.js';
3
- import type { BrokerSnapshot } from '../../core/runtime/broker-protocol.js';
3
+ import type { BrokerSnapshot, ClientRole } from '../../core/runtime/broker-protocol.js';
4
4
  import { type AttachPalette } from './config-load.js';
5
+ export type Mode = 'normal' | 'spec' | 'plan';
6
+ /** Resolve the mode badge's initial state. Remote never reads the local
7
+ * mode-switch `current` file for `nodeId` — always starts 'normal'. */
8
+ export declare function resolveInitialMode(cwd: string, nodeId: string, remote: boolean, readMode?: (cwd: string, nodeId: string) => Mode | undefined): Mode;
9
+ /** Handle Alt+M's mode-cycle request. Remote no-ops entirely (returns `false`,
10
+ * so the caller leaves `modeState`/the badge untouched) — there is no
11
+ * remote-safe mode-switch protocol, and writing the local `request.json` for a
12
+ * remote node id would target the wrong (or a colliding local) node. */
13
+ export declare function requestModeSwitch(cwd: string, nodeId: string, remote: boolean, mode: Mode, writeRequest?: (cwd: string, nodeId: string, mode: Mode) => void): boolean;
14
+ /** Tag/untag the attach pane with `nodeId` so the host-agnostic `nodeInPane()`
15
+ * (Alt+C cycle/recycle/close/demote) resolves this broker-hosted node from its
16
+ * viewer pane — LOCAL ONLY. Remote never sets `@crtr_node`, so local Alt+C
17
+ * lifecycle can never resolve a remote id (or a local id it collides with)
18
+ * from this pane. */
19
+ export declare function tagAttachPane(remote: boolean, pane: string | undefined, nodeId: string, setOpt?: (pane: string, name: string, value: string) => void): void;
20
+ export declare function untagAttachPane(remote: boolean, pane: string | undefined, setOpt?: (pane: string, name: string, value: string) => void): void;
21
+ /** A clean detach reaps an empty LOCAL node (an AI-output-free shell the user
22
+ * walked away from) — LOCAL ONLY. `reapIfEmpty` deletes a local canvas.db row;
23
+ * remote never calls it, so a remote attach can never delete a local node on
24
+ * detach even when the remote id collides with one. */
25
+ export declare function reapOnDetach(remote: boolean, nodeId: string, reap?: (nodeId: string) => void): void;
26
+ /** The hello handshake's initial role. A REMOTE attach (`--canvas`) is a
27
+ * strictly VIEW-ONLY surface (Phase 3 review Major 1) — it must claim
28
+ * observer regardless of `--observer`, never controller. Local attach keeps
29
+ * today's exact behavior: controller unless `--observer`. Exported pure so
30
+ * the "remote is always observer" contract is unit-testable without
31
+ * constructing a real hello/socket. */
32
+ export declare function resolveHelloRole(remote: boolean, observer: boolean): ClientRole;
5
33
  /** Derive the shell-mode cue from the editor contents. Leading whitespace is
6
34
  * ignored so pasted commands and indented text still toggle correctly. */
7
35
  export declare function deriveBashMode(text: string): 0 | 1 | 2;
@@ -13,7 +41,7 @@ export declare function resolveEditorFrameStyles(bashMode: 0 | 1 | 2, thinkingLe
13
41
  export declare function buildAttachAutocompleteProvider(cwd: string, fdPath: string | null, commands?: ReadonlyArray<{
14
42
  name: string;
15
43
  description?: string;
16
- }>, createProvider?: (items: ReadonlyArray<{
44
+ }>, remote?: boolean, createProvider?: (items: ReadonlyArray<{
17
45
  name: string;
18
46
  description?: string;
19
47
  }>, cwd: string, fdPath: string | null) => CombinedAutocompleteProvider): CombinedAutocompleteProvider;