@moxxy/plugin-channel-web 0.27.0

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel.d.ts +152 -0
  3. package/dist/channel.d.ts.map +1 -0
  4. package/dist/channel.js +595 -0
  5. package/dist/channel.js.map +1 -0
  6. package/dist/index.d.ts +53 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +171 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/projector.d.ts +17 -0
  11. package/dist/projector.d.ts.map +1 -0
  12. package/dist/projector.js +96 -0
  13. package/dist/projector.js.map +1 -0
  14. package/dist/protocol.d.ts +145 -0
  15. package/dist/protocol.d.ts.map +1 -0
  16. package/dist/protocol.js +33 -0
  17. package/dist/protocol.js.map +1 -0
  18. package/dist/public/app.js +54 -0
  19. package/dist/public/index.html +178 -0
  20. package/dist/tunnel-settings.d.ts +17 -0
  21. package/dist/tunnel-settings.d.ts.map +1 -0
  22. package/dist/tunnel-settings.js +42 -0
  23. package/dist/tunnel-settings.js.map +1 -0
  24. package/package.json +71 -0
  25. package/src/channel.test.ts +514 -0
  26. package/src/channel.ts +669 -0
  27. package/src/discovery.test.ts +40 -0
  28. package/src/frontend/chat.test.ts +47 -0
  29. package/src/frontend/chat.tsx +138 -0
  30. package/src/frontend/index.html +178 -0
  31. package/src/frontend/main.tsx +87 -0
  32. package/src/frontend/render-diff.test.ts +86 -0
  33. package/src/frontend/render-diff.tsx +66 -0
  34. package/src/frontend/render.test.ts +166 -0
  35. package/src/frontend/render.tsx +274 -0
  36. package/src/frontend/socket.ts +212 -0
  37. package/src/frontend/url-safety.test.ts +0 -0
  38. package/src/frontend/url-safety.ts +33 -0
  39. package/src/frontend/view-store.test.ts +104 -0
  40. package/src/frontend/view-store.ts +120 -0
  41. package/src/index.ts +212 -0
  42. package/src/projector.test.ts +172 -0
  43. package/src/projector.ts +95 -0
  44. package/src/protocol.test.ts +59 -0
  45. package/src/protocol.ts +105 -0
  46. package/src/tunnel-settings.test.ts +64 -0
  47. package/src/tunnel-settings.ts +57 -0
  48. package/src/tunnel-tools.test.ts +120 -0
@@ -0,0 +1,514 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { WebSocket } from 'ws';
3
+ import type { ClientSession, MoxxyEvent } from '@moxxy/sdk';
4
+ import {
5
+ freeTcpPortIfMoxxy,
6
+ WebChannel,
7
+ basePathFromUrl,
8
+ injectBaseHtml,
9
+ type FreePortDeps,
10
+ } from './channel.js';
11
+ import type { ServerFrame } from './protocol.js';
12
+ import type { TunnelProviderDef } from '@moxxy/sdk';
13
+
14
+ describe('base-path helpers', () => {
15
+ it('derives the base path from a tunnel URL', () => {
16
+ expect(basePathFromUrl('https://uuid.proxy.moxxy.ai/web')).toBe('/web');
17
+ expect(basePathFromUrl('https://uuid.proxy.moxxy.ai/web/')).toBe('/web');
18
+ expect(basePathFromUrl('https://uuid.proxy.moxxy.ai')).toBe('');
19
+ expect(basePathFromUrl('http://127.0.0.1:4040')).toBe('');
20
+ expect(basePathFromUrl('not a url')).toBe('');
21
+ });
22
+
23
+ it('injects the base href + client global, leaving root unchanged', () => {
24
+ const tpl = '<head><!--moxxy:base--></head><body><script src="app.js"></script></body>';
25
+ const web = injectBaseHtml(tpl, '/web');
26
+ expect(web).toContain('<base href="/web/" />');
27
+ expect(web).toContain('window.__MOXXY_BASE__="/web"');
28
+ expect(injectBaseHtml(tpl, '')).toContain('<base href="/" />');
29
+ expect(injectBaseHtml(tpl, '')).toContain('window.__MOXXY_BASE__=""');
30
+ });
31
+ });
32
+
33
+ const sampleDoc = { root: { kind: 'element', tag: 'view', props: { title: 'hi' }, children: [] } };
34
+
35
+ /** A fake session whose log fires real listeners and whose runTurn emits a present_view result. */
36
+ function fakeSession() {
37
+ const listeners = new Set<(e: MoxxyEvent) => void>();
38
+ const emit = (e: Record<string, unknown>) => {
39
+ const ev = { turnId: 't1', sessionId: 's1', source: 'system', id: 'e', seq: 0, ts: 0, ...e } as unknown as MoxxyEvent;
40
+ for (const fn of listeners) fn(ev);
41
+ };
42
+ const prompts: string[] = [];
43
+ const session = {
44
+ log: { subscribe: (fn: (e: MoxxyEvent) => void) => { listeners.add(fn); return () => listeners.delete(fn); } },
45
+ async *runTurn(prompt: string) {
46
+ prompts.push(prompt);
47
+ emit({ type: 'tool_call_requested', callId: 'c1', name: 'present_view', input: {} });
48
+ emit({ type: 'tool_result', callId: 'c1', ok: true, output: { ast: sampleDoc } });
49
+ emit({ type: 'assistant_message', content: 'here you go', stopReason: 'end_turn' });
50
+ },
51
+ } as unknown as ClientSession;
52
+ return { session, prompts, emit };
53
+ }
54
+
55
+ let channel: WebChannel | null = null;
56
+ let handle: { stop: () => Promise<void> } | null = null;
57
+ afterEach(async () => {
58
+ await handle?.stop();
59
+ channel = null;
60
+ handle = null;
61
+ });
62
+
63
+ async function startOn(session: ClientSession, token = 'tkn') {
64
+ channel = new WebChannel({ port: 0, host: '127.0.0.1', authToken: token });
65
+ handle = await channel.start({ session });
66
+ const u = new URL(channel.url);
67
+ return { base: `http://127.0.0.1:${u.port}`, wsBase: `ws://127.0.0.1:${u.port}`, token };
68
+ }
69
+
70
+ /** Open a ws and buffer every inbound frame from the moment it connects. */
71
+ async function connect(wsBase: string, token: string): Promise<{ ws: WebSocket; frames: ServerFrame[]; waitFor: (kind: ServerFrame['kind']) => Promise<ServerFrame> }> {
72
+ const ws = new WebSocket(`${wsBase}/ws?t=${token}`);
73
+ const frames: ServerFrame[] = [];
74
+ ws.on('message', (d) => frames.push(JSON.parse(String(d)) as ServerFrame));
75
+ await new Promise((r) => ws.once('open', r));
76
+ const waitFor = (kind: ServerFrame['kind']) =>
77
+ new Promise<ServerFrame>((resolve, reject) => {
78
+ const start = Date.now();
79
+ const tick = () => {
80
+ const hit = frames.find((f) => f.kind === kind);
81
+ if (hit) return resolve(hit);
82
+ if (Date.now() - start > 2000) return reject(new Error(`no ${kind} frame`));
83
+ setTimeout(tick, 10);
84
+ };
85
+ tick();
86
+ });
87
+ return { ws, frames, waitFor };
88
+ }
89
+
90
+ describe('WebChannel', () => {
91
+ it('serves health and rejects untokened index', async () => {
92
+ const { base } = await startOn(fakeSession().session);
93
+ const health = await fetch(`${base}/v1/health`);
94
+ expect(health.status).toBe(200);
95
+ const noTok = await fetch(`${base}/`);
96
+ expect(noTok.status).toBe(401);
97
+ });
98
+
99
+ it('rejects a WS handshake with a bad token (never opens)', async () => {
100
+ const { wsBase } = await startOn(fakeSession().session);
101
+ const ws = new WebSocket(`${wsBase}/ws?t=wrong`);
102
+ const opened = await new Promise<boolean>((resolve) => {
103
+ ws.on('open', () => resolve(true));
104
+ ws.on('error', () => resolve(false));
105
+ ws.on('unexpected-response', () => resolve(false));
106
+ });
107
+ expect(opened).toBe(false);
108
+ });
109
+
110
+ it('accepts a good token and sends hello', async () => {
111
+ const { wsBase, token } = await startOn(fakeSession().session);
112
+ const { ws, waitFor } = await connect(wsBase, token);
113
+ expect((await waitFor('hello')).kind).toBe('hello');
114
+ ws.close();
115
+ });
116
+
117
+ it('drives a turn from a prompt and pushes a view frame', async () => {
118
+ const { session, prompts } = fakeSession();
119
+ const { wsBase, token } = await startOn(session);
120
+ const { ws, frames, waitFor } = await connect(wsBase, token);
121
+ ws.send(JSON.stringify({ kind: 'prompt', text: 'find flights' }));
122
+ await waitFor('view');
123
+ expect(prompts).toEqual(['find flights']);
124
+ expect(frames.some((f) => f.kind === 'message' && f.role === 'assistant')).toBe(true);
125
+ ws.close();
126
+ });
127
+
128
+ it('translates a view action into a [ui-action] turn', async () => {
129
+ const { session, prompts } = fakeSession();
130
+ const { wsBase, token } = await startOn(session);
131
+ const { ws, waitFor } = await connect(wsBase, token);
132
+ ws.send(JSON.stringify({ kind: 'action', actionId: 'a1', viewId: null, action: { name: 'search_flights' }, formValues: { from: 'SFO' } }));
133
+ await waitFor('ack');
134
+ await waitFor('view');
135
+ expect(prompts[0]).toContain('[ui-action]');
136
+ expect(prompts[0]).toContain('search_flights');
137
+ expect(prompts[0]).toContain('SFO');
138
+ ws.close();
139
+ });
140
+
141
+ it('mirrors a foreign turn — pushes a view for events it did not initiate', async () => {
142
+ // Simulates a turn driven elsewhere (e.g. a Telegram message on a shared
143
+ // session): the web surface renders it purely from the log subscription.
144
+ const { session, emit, prompts } = fakeSession();
145
+ const { wsBase, token } = await startOn(session);
146
+ const { ws, waitFor } = await connect(wsBase, token);
147
+ emit({ type: 'tool_call_requested', callId: 'fc', name: 'present_view', input: {} });
148
+ emit({ type: 'tool_result', callId: 'fc', ok: true, output: { ast: sampleDoc } });
149
+ const view = await waitFor('view');
150
+ expect(view.kind).toBe('view');
151
+ expect(prompts).toEqual([]); // the web channel drove nothing
152
+ ws.close();
153
+ });
154
+
155
+ it('replays already-built views to a browser that connects later', async () => {
156
+ const { session, emit } = fakeSession();
157
+ const { wsBase, token } = await startOn(session);
158
+ // Build a view BEFORE any browser connects (the normal flow: build in TUI,
159
+ // then open the link).
160
+ emit({ type: 'tool_call_requested', callId: 'c', name: 'present_view', input: {} });
161
+ emit({
162
+ type: 'tool_result',
163
+ callId: 'c',
164
+ ok: true,
165
+ output: { ast: { root: { kind: 'element', tag: 'view', props: { name: 'search' }, children: [] } } },
166
+ });
167
+ // Now connect — the view must arrive via replay (no "No view yet").
168
+ const { ws, waitFor } = await connect(wsBase, token);
169
+ const v = await waitFor('view');
170
+ expect(v.kind).toBe('view');
171
+ ws.close();
172
+ });
173
+
174
+ it('publishes the surface URL via the active tunnel provider', async () => {
175
+ let published: { url: string; nextViewId: () => string } | null = null;
176
+ const fakeTunnel: TunnelProviderDef = {
177
+ name: 'fake',
178
+ open: () => Promise.resolve({ url: 'https://abc.trycloudflare.com', close: () => Promise.resolve() }),
179
+ };
180
+ channel = new WebChannel({
181
+ port: 0,
182
+ host: '127.0.0.1',
183
+ authToken: 'tkn',
184
+ getTunnel: () => fakeTunnel,
185
+ publishSurface: (s) => {
186
+ published = s;
187
+ },
188
+ });
189
+ handle = await channel.start({ session: fakeSession().session });
190
+ expect(published).not.toBeNull();
191
+ expect(published!.url).toBe('https://abc.trycloudflare.com/?t=tkn');
192
+ });
193
+
194
+ it('co-attached to a real Session publishes a localhost URL by default (the TUI case)', async () => {
195
+ const { Session } = await import('@moxxy/core');
196
+ const session = new Session({ cwd: '/tmp', silent: true });
197
+ let published: { url: string; nextViewId: () => string } | null = null;
198
+ channel = new WebChannel({
199
+ port: 0,
200
+ host: '127.0.0.1',
201
+ authToken: 'tkn',
202
+ getTunnel: () => session.tunnelProviders.getActive(), // core seeds 'localhost'
203
+ publishSurface: (s) => {
204
+ published = s;
205
+ },
206
+ });
207
+ handle = await channel.start({ session: session as never });
208
+ expect(published).not.toBeNull();
209
+ // A real, tokenized URL present_view can hand back — never null/rendered:false.
210
+ expect(published!.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/\?t=tkn$/);
211
+ });
212
+
213
+ it('falls back to the local URL when the tunnel provider fails', async () => {
214
+ let published: { url: string; nextViewId: () => string } | null = null;
215
+ const badTunnel: TunnelProviderDef = {
216
+ name: 'bad',
217
+ open: () => Promise.reject(new Error('relay unreachable')),
218
+ };
219
+ channel = new WebChannel({
220
+ port: 0,
221
+ host: '127.0.0.1',
222
+ authToken: 'tkn',
223
+ getTunnel: () => badTunnel,
224
+ publishSurface: (s) => {
225
+ published = s;
226
+ },
227
+ });
228
+ handle = await channel.start({ session: fakeSession().session });
229
+ expect(published).not.toBeNull();
230
+ expect(published!.url).toContain('http://127.0.0.1:');
231
+ expect(published!.url).toContain('?t=tkn');
232
+ });
233
+
234
+ it('opens a WS handshake under the relay base path (proxy /web)', async () => {
235
+ const token = 'tkn';
236
+ const webTunnel: TunnelProviderDef = {
237
+ name: 'proxy',
238
+ open: () =>
239
+ Promise.resolve({ url: 'https://uuid.proxy.moxxy.ai/web', close: () => Promise.resolve() }),
240
+ };
241
+ channel = new WebChannel({ port: 0, host: '127.0.0.1', authToken: token, getTunnel: () => webTunnel });
242
+ handle = await channel.start({ session: fakeSession().session });
243
+ const port = new URL(channel.url).port;
244
+
245
+ const openWs = (path: string, t: string) =>
246
+ new Promise<boolean>((resolve) => {
247
+ const ws = new WebSocket(`ws://127.0.0.1:${port}${path}?t=${t}`);
248
+ ws.on('open', () => {
249
+ resolve(true);
250
+ ws.close();
251
+ });
252
+ ws.on('error', () => resolve(false));
253
+ ws.on('unexpected-response', () => resolve(false));
254
+ });
255
+
256
+ // Under the base path AND root (relay may or may not strip) both open.
257
+ expect(await openWs('/web/ws', token)).toBe(true);
258
+ expect(await openWs('/ws', token)).toBe(true);
259
+ // Token still gates under the prefix.
260
+ expect(await openWs('/web/ws', 'wrong')).toBe(false);
261
+ });
262
+
263
+ it('rejects a view action while a turn is in flight (busy)', async () => {
264
+ // A runTurn that never resolves until released, so the channel stays busy.
265
+ let release!: () => void;
266
+ const gate = new Promise<void>((r) => {
267
+ release = r;
268
+ });
269
+ const session = {
270
+ log: { subscribe: () => () => undefined },
271
+ async *runTurn() {
272
+ await gate;
273
+ },
274
+ } as unknown as Parameters<WebChannel['start']>[0]['session'];
275
+ const { wsBase, token } = await startOn(session as never);
276
+ const { ws, waitFor, frames } = await connect(wsBase, token);
277
+ ws.send(JSON.stringify({ kind: 'prompt', text: 'go' })); // occupies the channel
278
+ ws.send(JSON.stringify({ kind: 'action', actionId: 'a2', viewId: null, action: { name: 'x' }, formValues: {} }));
279
+ const ack = await waitFor('ack');
280
+ expect(ack.kind === 'ack' && ack.accepted).toBe(false);
281
+ expect(frames.some((f) => f.kind === 'ack' && !f.accepted && f.reason === 'busy')).toBe(true);
282
+ release();
283
+ ws.close();
284
+ });
285
+
286
+ it('ignores malformed JSON without crashing', async () => {
287
+ const { session } = fakeSession();
288
+ const { wsBase, token } = await startOn(session);
289
+ const { ws, waitFor } = await connect(wsBase, token);
290
+ ws.send('not json{{{');
291
+ // Still responsive afterwards.
292
+ ws.send(JSON.stringify({ kind: 'prompt', text: 'find flights' }));
293
+ await waitFor('view');
294
+ ws.close();
295
+ });
296
+
297
+ it('drops schema-invalid frames (no throw, no turn driven) and stays responsive', async () => {
298
+ const { session, prompts } = fakeSession();
299
+ const { wsBase, token } = await startOn(session);
300
+ const { ws, waitFor } = await connect(wsBase, token);
301
+ // The historical crasher: valid JSON, missing required field →
302
+ // `frame.text.trim()` threw inside the ws 'message' listener and the
303
+ // TypeError escalated to a process-level uncaughtException.
304
+ ws.send(JSON.stringify({ kind: 'prompt' }));
305
+ ws.send(JSON.stringify({ kind: 'action' })); // no actionId/action/formValues
306
+ ws.send(JSON.stringify({ kind: 'action', actionId: 'a', viewId: null, formValues: {} })); // no action
307
+ ws.send(JSON.stringify({ kind: 'prompt', text: 42 })); // wrong field type
308
+ ws.send(JSON.stringify({ kind: 'nonsense' })); // unknown kind
309
+ ws.send(JSON.stringify({})); // no kind at all
310
+ ws.send('junk{{{'); // not JSON
311
+ ws.send('"' + 'x'.repeat(300 * 1024) + '"'); // oversized garbage (> MAX_FRAME_BYTES)
312
+ // Same socket, valid frame: the channel must still be alive + responsive,
313
+ // and none of the invalid frames may have driven a turn.
314
+ ws.send(JSON.stringify({ kind: 'prompt', text: 'still alive' }));
315
+ await waitFor('view');
316
+ expect(prompts).toEqual(['still alive']);
317
+ ws.close();
318
+ });
319
+
320
+ it('rate-limits the invalid-frame warn log', async () => {
321
+ const warns: string[] = [];
322
+ const { session } = fakeSession();
323
+ channel = new WebChannel({
324
+ port: 0,
325
+ host: '127.0.0.1',
326
+ authToken: 'tkn',
327
+ logger: { warn: (msg) => warns.push(msg) },
328
+ });
329
+ handle = await channel.start({ session });
330
+ const u = new URL(channel.url);
331
+ const { ws, waitFor } = await connect(`ws://127.0.0.1:${u.port}`, 'tkn');
332
+ for (let i = 0; i < 25; i++) ws.send(JSON.stringify({ kind: 'prompt' }));
333
+ ws.send(JSON.stringify({ kind: 'prompt', text: 'go' }));
334
+ await waitFor('view');
335
+ expect(warns.filter((m) => m.includes('dropped invalid client frame'))).toHaveLength(1);
336
+ ws.close();
337
+ });
338
+
339
+ it('falls back to an ephemeral port when a non-moxxy process holds its port', async () => {
340
+ // Occupy a port with a server owned by THIS (non-target) process — the
341
+ // channel must not signal anything and must bind an ephemeral port.
342
+ const { createServer } = await import('node:http');
343
+ const squatter = createServer(() => undefined);
344
+ await new Promise<void>((resolve) => squatter.listen(0, '127.0.0.1', resolve));
345
+ const taken = (squatter.address() as { port: number }).port;
346
+ const warns: string[] = [];
347
+ try {
348
+ channel = new WebChannel({
349
+ port: taken,
350
+ host: '127.0.0.1',
351
+ authToken: 'tkn',
352
+ logger: { warn: (msg) => warns.push(msg) },
353
+ });
354
+ handle = await channel.start({ session: fakeSession().session });
355
+ const bound = Number(new URL(channel.url).port);
356
+ expect(bound).not.toBe(taken);
357
+ expect(bound).toBeGreaterThan(0);
358
+ // The squatter survived (no kill) …
359
+ expect(squatter.listening).toBe(true);
360
+ // … the fallback was logged loudly …
361
+ expect(warns.some((m) => m.includes(`bound ephemeral port ${bound}`))).toBe(true);
362
+ // … and the channel actually works on the fallback port.
363
+ const health = await fetch(`http://127.0.0.1:${bound}/v1/health`);
364
+ expect(health.status).toBe(200);
365
+ } finally {
366
+ await new Promise<void>((resolve) => squatter.close(() => resolve()));
367
+ }
368
+ });
369
+
370
+ it('broadcasts a view to multiple connected clients', async () => {
371
+ const { session, emit } = fakeSession();
372
+ const { wsBase, token } = await startOn(session);
373
+ const c1 = await connect(wsBase, token);
374
+ const c2 = await connect(wsBase, token);
375
+ emit({ type: 'tool_call_requested', callId: 'm', name: 'present_view', input: {} });
376
+ emit({ type: 'tool_result', callId: 'm', ok: true, output: { ast: sampleDoc } });
377
+ await c1.waitFor('view');
378
+ await c2.waitFor('view');
379
+ c1.ws.close();
380
+ c2.ws.close();
381
+ });
382
+
383
+ it('serves 404 for unknown routes', async () => {
384
+ const { base } = await startOn(fakeSession().session);
385
+ expect((await fetch(`${base}/nope`)).status).toBe(404);
386
+ });
387
+
388
+ it('emits defense-in-depth security headers on the index response', async () => {
389
+ // The headers are unconditional on the index path: they ride the 200 with
390
+ // the bundle present and the 500 when it's missing (the case under vitest,
391
+ // which runs from src/ with no built dist/public), so a missing bundle never
392
+ // serves a page without the clickjacking / referrer-token protections.
393
+ const { base, token } = await startOn(fakeSession().session);
394
+ const res = await fetch(`${base}/?t=${token}`);
395
+ const csp = res.headers.get('content-security-policy') ?? '';
396
+ expect(csp).toContain("script-src 'self'");
397
+ expect(csp).toContain("frame-ancestors 'none'");
398
+ expect(res.headers.get('x-frame-options')).toBe('DENY');
399
+ expect(res.headers.get('x-content-type-options')).toBe('nosniff');
400
+ expect(res.headers.get('referrer-policy')).toBe('no-referrer');
401
+ });
402
+
403
+ it('bounds the replay set: an unbounded stream of unnamed views collapses to one', async () => {
404
+ // A pathological agent that presents many UNNAMED views must not leak one
405
+ // ViewDoc per render, and a late joiner must not receive the whole history.
406
+ const { session, emit } = fakeSession();
407
+ const { wsBase, token } = await startOn(session);
408
+ for (let i = 0; i < 200; i++) {
409
+ emit({ type: 'tool_call_requested', callId: `u${i}`, name: 'present_view', input: {} });
410
+ emit({ type: 'tool_result', callId: `u${i}`, ok: true, output: { ast: sampleDoc } });
411
+ }
412
+ const { ws, frames, waitFor } = await connect(wsBase, token);
413
+ await waitFor('view');
414
+ // Give replay a beat to flush, then assert the late joiner saw exactly ONE
415
+ // replayed view, not 200.
416
+ await new Promise((r) => setTimeout(r, 50));
417
+ expect(frames.filter((f) => f.kind === 'view')).toHaveLength(1);
418
+ ws.close();
419
+ });
420
+
421
+ it('bounds the replay set across many distinct NAMED views (LRU-capped)', async () => {
422
+ const { session, emit } = fakeSession();
423
+ const { wsBase, token } = await startOn(session);
424
+ for (let i = 0; i < 100; i++) {
425
+ emit({ type: 'tool_call_requested', callId: `n${i}`, name: 'present_view', input: {} });
426
+ emit({
427
+ type: 'tool_result',
428
+ callId: `n${i}`,
429
+ ok: true,
430
+ output: { ast: { root: { kind: 'element', tag: 'view', props: { name: `screen${i}` }, children: [] } } },
431
+ });
432
+ }
433
+ const { ws, frames, waitFor } = await connect(wsBase, token);
434
+ await waitFor('view');
435
+ await new Promise((r) => setTimeout(r, 50));
436
+ // 100 distinct named screens were built, but replay is capped at 32.
437
+ expect(frames.filter((f) => f.kind === 'view').length).toBeLessThanOrEqual(32);
438
+ ws.close();
439
+ });
440
+
441
+ it('clears the published surface on stop', async () => {
442
+ let published: { url: string; nextViewId: () => string } | null = { url: 'stale', nextViewId: () => 'x' };
443
+ channel = new WebChannel({
444
+ port: 0,
445
+ host: '127.0.0.1',
446
+ authToken: 'tkn',
447
+ publishSurface: (s) => {
448
+ published = s;
449
+ },
450
+ });
451
+ handle = await channel.start({ session: fakeSession().session });
452
+ expect(published).not.toBeNull();
453
+ await handle.stop();
454
+ handle = null; // already stopped
455
+ expect(published).toBeNull();
456
+ });
457
+ });
458
+
459
+ describe.skipIf(process.platform === 'win32')('freeTcpPortIfMoxxy: identity-gate TOCTOU', () => {
460
+ function mkDeps(over: Partial<FreePortDeps> & { commandsByPid: Record<number, string[]> }): {
461
+ deps: FreePortDeps;
462
+ killed: Array<{ pid: number; signal: number | NodeJS.Signals }>;
463
+ } {
464
+ const killed: Array<{ pid: number; signal: number | NodeJS.Signals }> = [];
465
+ // Each PID gets a queue of commands returned on successive pidCommand reads,
466
+ // so a PID can "change identity" between the snapshot and the kill.
467
+ const calls: Record<number, number> = {};
468
+ const deps: FreePortDeps = {
469
+ pidsListeningOn: async () => Object.keys(over.commandsByPid).map(Number),
470
+ pidCommand: async (pid) => {
471
+ const seq = over.commandsByPid[pid] ?? [];
472
+ const i = Math.min(calls[pid] ?? 0, seq.length - 1);
473
+ calls[pid] = (calls[pid] ?? 0) + 1;
474
+ return seq[i] ?? '';
475
+ },
476
+ kill: (pid, signal) => {
477
+ killed.push({ pid, signal });
478
+ },
479
+ graceMs: 0,
480
+ ...over,
481
+ };
482
+ return { deps, killed };
483
+ }
484
+
485
+ it('skips the kill when a moxxy PID is reused by a foreign process before SIGTERM', async () => {
486
+ // 4242 passes the initial snapshot as moxxy, then its command flips to a
487
+ // foreign process before the kill: it must NOT be signalled.
488
+ const { deps, killed } = mkDeps({
489
+ commandsByPid: { 4242: ['node /usr/local/bin/moxxy serve', '/usr/bin/postgres'] },
490
+ });
491
+ const result = await freeTcpPortIfMoxxy(4242, undefined, deps);
492
+ expect(result).toBe(false);
493
+ expect(killed).toEqual([]);
494
+ });
495
+
496
+ it('kills a PID that stays moxxy through the re-check (SIGTERM then SIGKILL)', async () => {
497
+ // Stays moxxy on every read; still "alive" on the kill(pid,0) probe.
498
+ const { deps, killed } = mkDeps({
499
+ commandsByPid: { 5252: ['node moxxy serve', 'node moxxy serve', 'node moxxy serve'] },
500
+ });
501
+ const result = await freeTcpPortIfMoxxy(5252, undefined, deps);
502
+ expect(result).toBe(true);
503
+ expect(killed.map((k) => k.signal)).toEqual(['SIGTERM', 0, 'SIGKILL']);
504
+ });
505
+
506
+ it('leaves a foreign-from-the-start holder untouched', async () => {
507
+ const { deps, killed } = mkDeps({
508
+ commandsByPid: { 6262: ['/usr/sbin/sshd'] },
509
+ });
510
+ const result = await freeTcpPortIfMoxxy(6262, undefined, deps);
511
+ expect(result).toBe(false);
512
+ expect(killed).toEqual([]);
513
+ });
514
+ });