@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
package/src/channel.ts ADDED
@@ -0,0 +1,669 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import * as path from 'node:path';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { WebSocketServer, type WebSocket } from 'ws';
8
+ import { createAllowListResolver } from '@moxxy/sdk';
9
+ import { bearerTokenMatches } from '@moxxy/sdk/server';
10
+ import type {
11
+ Channel,
12
+ ChannelHandle,
13
+ ChannelStartOptsBase,
14
+ ClientSession,
15
+ PermissionResolver,
16
+ TunnelHandle,
17
+ TunnelProviderDef,
18
+ } from '@moxxy/sdk';
19
+ import { EventProjector } from './projector.js';
20
+ import { actionPrompt, clientFrameSchema, type ClientFrame, type ServerFrame } from './protocol.js';
21
+
22
+ /** Hard cap on an inbound WS frame; anything larger is garbage, not a prompt. */
23
+ const MAX_FRAME_BYTES = 256 * 1024;
24
+ /** Invalid-frame warnings are rate-limited to one per this window. */
25
+ const DROP_WARN_INTERVAL_MS = 10_000;
26
+ /**
27
+ * Cap on distinct NAMED screens kept for replay. Named views replace in place
28
+ * (bounded by an app's screen count) but we still LRU-evict so a pathological
29
+ * agent that mints unbounded distinct names can't grow the map without limit.
30
+ */
31
+ const MAX_REPLAY_VIEWS = 32;
32
+ /**
33
+ * Replay-map slot shared by every UNNAMED view (latest wins). The leading
34
+ * newline guarantees no collision with an agent-chosen `<view name>` (view
35
+ * names can't contain control chars), so a real named screen never lands here.
36
+ */
37
+ const UNNAMED_VIEW_KEY = '\n__current__';
38
+
39
+ function isAddrInUse(err: unknown): boolean {
40
+ return (
41
+ !!err &&
42
+ typeof err === 'object' &&
43
+ (err as { code?: string }).code === 'EADDRINUSE'
44
+ );
45
+ }
46
+
47
+ /** Run a command and collect its stdout. Empty string on any failure. */
48
+ async function captureStdout(cmd: string, args: ReadonlyArray<string>): Promise<string> {
49
+ const { spawn } = await import('node:child_process');
50
+ return await new Promise<string>((resolve) => {
51
+ let out = '';
52
+ try {
53
+ const child = spawn(cmd, [...args], { stdio: ['ignore', 'pipe', 'ignore'] });
54
+ child.stdout.on('data', (b) => {
55
+ out += b.toString();
56
+ });
57
+ child.on('error', () => resolve(''));
58
+ child.on('close', () => resolve(out));
59
+ } catch {
60
+ resolve('');
61
+ }
62
+ });
63
+ }
64
+
65
+ /** PIDs actively LISTENing on a TCP port (lsof; empty on Windows / no lsof). */
66
+ async function pidsListeningOn(port: number): Promise<ReadonlyArray<number>> {
67
+ if (process.platform === 'win32') return [];
68
+ const out = await captureStdout('lsof', ['-t', `-iTCP:${port}`, '-sTCP:LISTEN']);
69
+ const found = new Set<number>();
70
+ for (const line of out.split('\n')) {
71
+ const n = parseInt(line.trim(), 10);
72
+ if (Number.isFinite(n) && n > 0) found.add(n);
73
+ }
74
+ return [...found];
75
+ }
76
+
77
+ /** A PID's command line via `ps`. Empty when the process is gone / unknowable. */
78
+ async function pidCommand(pid: number): Promise<string> {
79
+ return (await captureStdout('ps', ['-p', String(pid), '-o', 'command='])).trim();
80
+ }
81
+
82
+ /** Identity gate: only ever signal processes that look like moxxy's own
83
+ * (CLI bin / `moxxy serve` daemon / desktop app). An unidentifiable
84
+ * command line fails the gate — never kill what we can't name. */
85
+ function looksLikeMoxxy(command: string): boolean {
86
+ return command.length > 0 && /moxxy/i.test(command);
87
+ }
88
+
89
+ /** Injectable seams so the kill path can be unit-tested without real processes. */
90
+ export interface FreePortDeps {
91
+ pidsListeningOn(port: number): Promise<ReadonlyArray<number>>;
92
+ pidCommand(pid: number): Promise<string>;
93
+ kill(pid: number, signal: number | NodeJS.Signals): void;
94
+ /** Grace delay between SIGTERM and the SIGKILL sweep. */
95
+ graceMs?: number;
96
+ }
97
+
98
+ const realFreePortDeps: FreePortDeps = {
99
+ pidsListeningOn,
100
+ pidCommand,
101
+ kill: (pid, signal) => process.kill(pid, signal),
102
+ graceMs: 400,
103
+ };
104
+
105
+ /**
106
+ * Free a TCP port ONLY if every process holding it is a moxxy process
107
+ * (stale `moxxy serve` leftovers — legitimate self-healing). Returns true
108
+ * when a kill was attempted. Anything else holding the port (the default,
109
+ * 4040, is also ngrok's local-UI port!) is left alone — the caller falls
110
+ * back to an ephemeral port instead. SIGTERM → grace → SIGKILL.
111
+ */
112
+ export async function freeTcpPortIfMoxxy(
113
+ port: number,
114
+ logger: WebChannelOptions['logger'],
115
+ deps: FreePortDeps = realFreePortDeps,
116
+ ): Promise<boolean> {
117
+ if (process.platform === 'win32') return false;
118
+ const pids = (await deps.pidsListeningOn(port)).filter((pid) => pid !== process.pid);
119
+ if (pids.length === 0) return false;
120
+ const holders = await Promise.all(
121
+ pids.map(async (pid) => ({ pid, command: await deps.pidCommand(pid) })),
122
+ );
123
+ const foreign = holders.filter((h) => !looksLikeMoxxy(h.command));
124
+ if (foreign.length > 0) {
125
+ logger?.warn?.(`port ${port} is held by non-moxxy process(es); not killing them`, {
126
+ holders: foreign.map((h) => `${h.pid}: ${h.command || '<unknown command>'}`),
127
+ });
128
+ return false;
129
+ }
130
+ // Re-verify identity immediately before each signal. Between the `ps`
131
+ // snapshot above and the kill, a holder PID can exit and be reused by an
132
+ // unrelated process — so re-read its command and skip any whose name no
133
+ // longer looks like moxxy. Narrows (can't fully close — POSIX signalling
134
+ // is inherently racy) the TOCTOU on the identity gate.
135
+ let attempted = false;
136
+ for (const { pid } of holders) {
137
+ const command = await deps.pidCommand(pid);
138
+ if (!looksLikeMoxxy(command)) continue;
139
+ try {
140
+ // Log the exact command we judged moxxy-owned before signalling, so a
141
+ // mis-fire on a substring-collision process is auditable after the fact.
142
+ logger?.warn?.('freeing port: SIGTERM to apparent stale moxxy process', { pid, command });
143
+ deps.kill(pid, 'SIGTERM');
144
+ attempted = true;
145
+ } catch {
146
+ /* may already be gone */
147
+ }
148
+ }
149
+ if (!attempted) return false;
150
+ await new Promise((r) => setTimeout(r, deps.graceMs ?? 400));
151
+ for (const { pid } of holders) {
152
+ // Re-check identity again before escalating to SIGKILL.
153
+ try {
154
+ deps.kill(pid, 0);
155
+ } catch {
156
+ continue; /* dead — nothing to escalate */
157
+ }
158
+ const command = await deps.pidCommand(pid);
159
+ if (!looksLikeMoxxy(command)) continue;
160
+ try {
161
+ logger?.warn?.('freeing port: SIGKILL to apparent stale moxxy process', { pid, command });
162
+ deps.kill(pid, 'SIGKILL');
163
+ } catch {
164
+ /* dead */
165
+ }
166
+ }
167
+ return true;
168
+ }
169
+
170
+ /** Where `scripts/build-web.mjs` writes the browser bundle (relative to dist/channel.js). */
171
+ const PUBLIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
172
+
173
+ /** The path the tunnel exposes us under (e.g. `/web`), or `''` for the root. */
174
+ export function basePathFromUrl(url: string): string {
175
+ try {
176
+ const p = new URL(url).pathname;
177
+ return p === '/' ? '' : p.replace(/\/+$/, '');
178
+ } catch {
179
+ return '';
180
+ }
181
+ }
182
+
183
+ /** Inject the base path into index.html so relative assets (`app.js`) resolve
184
+ * under it and the client builds the right WS URL — works at root or `/web`. */
185
+ export function injectBaseHtml(html: string, basePath: string): string {
186
+ const inject =
187
+ `<base href="${basePath}/" />` +
188
+ `<script>window.__MOXXY_BASE__=${JSON.stringify(basePath)}</script>`;
189
+ return html.replace('<!--moxxy:base-->', inject);
190
+ }
191
+
192
+ export interface WebChannelOptions {
193
+ readonly port?: number;
194
+ readonly host?: string;
195
+ /** Token gating every request + the WS handshake. Generated if unset. */
196
+ readonly authToken?: string;
197
+ /** Tools the model may call without a human prompt (no clicker in this loop). */
198
+ readonly allowedTools?: ReadonlyArray<string>;
199
+ /** Resolve the session's active tunnel provider (injected by the CLI builder). */
200
+ readonly getTunnel?: () => TunnelProviderDef | null;
201
+ /**
202
+ * Publish/clear the live surface so `present_view` can return the public URL
203
+ * the agent relays to the user. Called with the surface on start, null on stop.
204
+ */
205
+ readonly publishSurface?: (surface: { url: string; nextViewId: () => string } | null) => void;
206
+ /**
207
+ * Publish/clear live controls so the agent's `web_set_tunnel` tool can switch
208
+ * the tunnel without a restart. `retunnel` closes the current tunnel (no leak)
209
+ * and re-opens via the now-active provider, returning the new share URL.
210
+ */
211
+ readonly publishControls?: (controls: WebSurfaceControls | null) => void;
212
+ readonly logger?: {
213
+ info?(msg: string, meta?: Record<string, unknown>): void;
214
+ warn?(msg: string, meta?: Record<string, unknown>): void;
215
+ };
216
+ }
217
+
218
+ export interface WebSurfaceControls {
219
+ retunnel(): Promise<string | null>;
220
+ }
221
+
222
+ export interface WebStartOpts extends ChannelStartOptsBase {
223
+ readonly session: ClientSession;
224
+ }
225
+
226
+ export class WebChannel implements Channel<WebStartOpts> {
227
+ readonly name = 'web';
228
+ readonly permissionResolver: PermissionResolver;
229
+ private port: number;
230
+ private readonly host: string;
231
+ private readonly token: string;
232
+ private readonly logger: WebChannelOptions['logger'];
233
+ private readonly getTunnel: WebChannelOptions['getTunnel'];
234
+ private readonly publishSurface: WebChannelOptions['publishSurface'];
235
+ private readonly publishControls: WebChannelOptions['publishControls'];
236
+ private server: Server | null = null;
237
+ private wss: WebSocketServer | null = null;
238
+ private readonly clients = new Set<WebSocket>();
239
+ /**
240
+ * Built screens replayed to a newly-connected browser, keyed by logical
241
+ * `name`. UNNAMED views all share {@link UNNAMED_VIEW_KEY} (latest wins), so
242
+ * an agent that presents many unnamed views over a long session can't leak
243
+ * one ViewDoc per render. Named views LRU-evict at {@link MAX_REPLAY_VIEWS}.
244
+ */
245
+ private readonly views = new Map<string, ServerFrame>();
246
+ private unsubscribe: (() => void) | null = null;
247
+ private session: ClientSession | null = null;
248
+ private busy = false;
249
+ private controller: AbortController | null = null;
250
+ private tunnel: TunnelHandle | null = null;
251
+ private tunnelBase: string | null = null;
252
+ /** Public path the surface is served under (e.g. `/web` behind the relay; `''` local). */
253
+ private basePath = '';
254
+ private viewSeq = 0;
255
+ private droppedFrames = 0;
256
+ private lastDropWarnAt = 0;
257
+
258
+ constructor(opts: WebChannelOptions = {}) {
259
+ this.port = opts.port ?? 4040;
260
+ this.host = opts.host ?? '127.0.0.1';
261
+ this.token = opts.authToken ?? randomBytes(16).toString('hex');
262
+ this.logger = opts.logger;
263
+ this.getTunnel = opts.getTunnel;
264
+ this.publishSurface = opts.publishSurface;
265
+ this.publishControls = opts.publishControls;
266
+ // The interactive surface is the gate; tools still need an upfront
267
+ // allow-list (no per-call clicker). Default to present_view + the read-only
268
+ // fetch tools so apps can pull REAL data out of the box. Extend via
269
+ // config.allowedTools. (When co-attached, the PRIMARY channel's resolver
270
+ // governs instead — e.g. the TUI prompts per tool.)
271
+ const allowed =
272
+ opts.allowedTools && opts.allowedTools.length > 0
273
+ ? [...opts.allowedTools]
274
+ : ['present_view', 'web_fetch', 'browser_session'];
275
+ this.permissionResolver = createAllowListResolver(allowed);
276
+ }
277
+
278
+ /** The local URL (token embedded). */
279
+ get url(): string {
280
+ return `http://${this.host}:${this.port}/?t=${this.token}`;
281
+ }
282
+
283
+ /** The URL to hand the user — the tunnel base if open, else local. */
284
+ get shareUrl(): string {
285
+ const base = this.tunnelBase ?? `http://${this.host}:${this.port}`;
286
+ return `${base}/?t=${this.token}`;
287
+ }
288
+
289
+ async start(startOpts: WebStartOpts): Promise<ChannelHandle> {
290
+ this.session = startOpts.session;
291
+ const projector = new EventProjector();
292
+ this.unsubscribe = startOpts.session.log.subscribe((event) => {
293
+ for (const frame of projector.project(event)) {
294
+ // Remember each screen so a browser that connects AFTER the agent built
295
+ // the app (the normal flow: build in TUI/Telegram → open the link) still
296
+ // sees it. Named screens replace in place; unnamed ones coalesce into a
297
+ // single latest-wins slot so the replay set stays bounded.
298
+ if (frame.kind === 'view') this.rememberView(frame);
299
+ this.broadcast(frame);
300
+ }
301
+ });
302
+
303
+ const server = createServer((req, res) => {
304
+ void this.handleHttp(req, res);
305
+ });
306
+ this.server = server;
307
+
308
+ // Bind FIRST: ws re-emits the http server's 'error' events on the
309
+ // WebSocketServer, so a WSS attached before a failed listen turns a
310
+ // recoverable EADDRINUSE into an unhandled 'error' → process crash.
311
+ await this.bindServerWithRetry(server);
312
+
313
+ // Validate the token at the handshake so a bad token is rejected with 401
314
+ // and the client never opens (the token is the only public-internet gate).
315
+ const wss = new WebSocketServer({
316
+ server,
317
+ // No fixed `path`: behind the relay the upgrade may arrive as `/ws` or
318
+ // `/<base>/ws`; verifyClient base-strips and checks it (plus the token).
319
+ // Frames past maxPayload are dropped at the socket layer (ws closes with
320
+ // 1009) instead of being buffered; onMessage applies a tighter cap.
321
+ maxPayload: 1024 * 1024,
322
+ verifyClient: (info: { req: IncomingMessage }) => {
323
+ const p = this.stripBase((info.req.url ?? '/').split('?')[0] ?? '/');
324
+ return p === '/ws' && this.validToken(info.req.url);
325
+ },
326
+ });
327
+ this.wss = wss;
328
+ wss.on('connection', (ws) => this.onConnection(ws));
329
+ // Never leave an EventEmitter 'error' unhandled — it would throw at the
330
+ // process level. Forwarded server errors after bind are log-and-survive.
331
+ wss.on('error', (err) => this.logger?.warn?.('web socket server error', { err: String(err) }));
332
+
333
+ await this.openTunnel();
334
+ this.publishSurface?.({ url: this.shareUrl, nextViewId: () => `v_srv_${++this.viewSeq}` });
335
+ this.publishControls?.({ retunnel: () => this.retunnel() });
336
+
337
+ const running = new Promise<void>((resolve) => server.once('close', () => resolve()));
338
+ return { running, stop: () => this.stop() };
339
+ }
340
+
341
+ /**
342
+ * Bind the HTTP server, with recovery if the port is already in use.
343
+ * A stale `moxxy serve` from a prior install often leaves 4040 bound
344
+ * even after its unix socket has been released — if (and only if) the
345
+ * holder is verifiably a moxxy process we kill it and retry. Anything
346
+ * else (ngrok's local UI also defaults to 4040) is never signalled;
347
+ * we bind an ephemeral port instead and log loudly which port the
348
+ * surface actually got (the share URL embeds the real port either way).
349
+ */
350
+ private async bindServerWithRetry(server: ReturnType<typeof createServer>): Promise<void> {
351
+ const tryListen = (): Promise<void> =>
352
+ new Promise<void>((resolve, reject) => {
353
+ const onError = (err: Error): void => {
354
+ server.off('listening', onListening);
355
+ reject(err);
356
+ };
357
+ const onListening = (): void => {
358
+ server.off('error', onError);
359
+ const addr = server.address();
360
+ if (addr && typeof addr === 'object') this.port = (addr as AddressInfo).port;
361
+ this.logger?.info?.('web channel listening', { url: this.url });
362
+ resolve();
363
+ };
364
+ server.once('error', onError);
365
+ server.once('listening', onListening);
366
+ server.listen(this.port, this.host);
367
+ });
368
+
369
+ try {
370
+ await tryListen();
371
+ return;
372
+ } catch (err) {
373
+ if (!isAddrInUse(err)) throw err;
374
+ }
375
+
376
+ const requested = this.port;
377
+ const freed = await freeTcpPortIfMoxxy(requested, this.logger).catch(() => false);
378
+ if (freed) {
379
+ this.logger?.warn?.(
380
+ `web channel port ${requested} was held by a stale moxxy process; freed it, retrying`,
381
+ );
382
+ try {
383
+ await tryListen();
384
+ return;
385
+ } catch (err) {
386
+ if (!isAddrInUse(err)) throw err;
387
+ }
388
+ }
389
+
390
+ // The holder is not ours to kill (or would not die) — take an
391
+ // ephemeral port. onListening reads back the real bound port, so
392
+ // this.url / shareUrl / the tunnel all carry it automatically.
393
+ this.port = 0;
394
+ await tryListen();
395
+ this.logger?.warn?.(
396
+ `web channel port ${requested} was in use by another process; bound ephemeral port ${this.port} instead`,
397
+ { requestedPort: requested, boundPort: this.port, url: this.url },
398
+ );
399
+ }
400
+
401
+ /**
402
+ * (Re-)open the tunnel via the active provider, closing any prior one FIRST so
403
+ * a switch never leaks a subprocess. Non-fatal: on failure (e.g. the relay
404
+ * not installed) we fall back to the local URL.
405
+ */
406
+ private async openTunnel(): Promise<void> {
407
+ if (this.tunnel) {
408
+ try {
409
+ await this.tunnel.close();
410
+ } catch {
411
+ /* ignore */
412
+ }
413
+ this.tunnel = null;
414
+ this.tunnelBase = null;
415
+ this.basePath = '';
416
+ }
417
+ const provider = this.getTunnel?.() ?? null;
418
+ if (!provider || provider.name === 'localhost') return;
419
+ try {
420
+ // Route the preview under the `web` path so the relay can multiplex it
421
+ // alongside the mobile bridge under one uuid subdomain.
422
+ this.tunnel = await provider.open({ port: this.port, host: this.host, label: 'web' });
423
+ this.tunnelBase = this.tunnel.url;
424
+ this.basePath = basePathFromUrl(this.tunnel.url);
425
+ this.logger?.info?.('web surface tunnel open', { provider: provider.name, url: this.shareUrl });
426
+ } catch (err) {
427
+ this.logger?.warn?.('web surface tunnel failed; using local URL', { provider: provider.name, err: String(err) });
428
+ }
429
+ }
430
+
431
+ /** Switch tunnels live (agent's web_set_tunnel) and republish the surface URL. */
432
+ private async retunnel(): Promise<string | null> {
433
+ await this.openTunnel();
434
+ this.publishSurface?.({ url: this.shareUrl, nextViewId: () => `v_srv_${++this.viewSeq}` });
435
+ return this.shareUrl;
436
+ }
437
+
438
+ private async stop(): Promise<void> {
439
+ this.publishSurface?.(null);
440
+ this.publishControls?.(null);
441
+ this.unsubscribe?.();
442
+ this.unsubscribe = null;
443
+ this.controller?.abort();
444
+ if (this.tunnel) {
445
+ try {
446
+ await this.tunnel.close();
447
+ } catch {
448
+ /* ignore */
449
+ }
450
+ this.tunnel = null;
451
+ this.tunnelBase = null;
452
+ this.basePath = '';
453
+ }
454
+ for (const ws of this.clients) {
455
+ try {
456
+ ws.close();
457
+ } catch {
458
+ /* ignore */
459
+ }
460
+ }
461
+ this.clients.clear();
462
+ await new Promise<void>((resolve) => (this.wss ? this.wss.close(() => resolve()) : resolve()));
463
+ await new Promise<void>((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
464
+ }
465
+
466
+ private validToken(reqUrl: string | undefined): boolean {
467
+ try {
468
+ // Constant-time compare so the token isn't recoverable byte-by-byte via
469
+ // response timing (this is the only public-internet gate).
470
+ const presented = new URL(reqUrl ?? '/', 'http://localhost').searchParams.get('t');
471
+ return bearerTokenMatches(presented, this.token);
472
+ } catch {
473
+ return false;
474
+ }
475
+ }
476
+
477
+ /** Strip the public base path (if any) so internal routing stays root-relative.
478
+ * Tolerant: handles both `/web/app.js` (relay didn't strip) and `/app.js`
479
+ * (relay stripped the first request line). */
480
+ private stripBase(pathname: string): string {
481
+ const b = this.basePath;
482
+ if (b && (pathname === b || pathname.startsWith(`${b}/`))) {
483
+ const rest = pathname.slice(b.length);
484
+ return rest === '' ? '/' : rest;
485
+ }
486
+ return pathname;
487
+ }
488
+
489
+ private async handleHttp(req: IncomingMessage, res: ServerResponse): Promise<void> {
490
+ const pathname = this.stripBase((req.url ?? '/').split('?')[0] ?? '/');
491
+ if (pathname === '/v1/health') {
492
+ res.writeHead(200, { 'content-type': 'application/json' });
493
+ res.end('{"status":"ok"}');
494
+ return;
495
+ }
496
+ if (req.method === 'GET' && (pathname === '/' || pathname === '/index.html')) {
497
+ if (!this.validToken(req.url)) {
498
+ res.writeHead(401, { 'content-type': 'text/plain' });
499
+ res.end('unauthorized — open the tokenized URL the agent gave you');
500
+ return;
501
+ }
502
+ await this.serveFile(res, 'index.html', 'text/html; charset=utf-8');
503
+ return;
504
+ }
505
+ if (req.method === 'GET' && pathname === '/app.js') {
506
+ await this.serveFile(res, 'app.js', 'text/javascript; charset=utf-8');
507
+ return;
508
+ }
509
+ res.writeHead(404, { 'content-type': 'text/plain' });
510
+ res.end('not found');
511
+ }
512
+
513
+ /**
514
+ * Defense-in-depth headers for the internet-exposed surface (served over
515
+ * public tunnels). CSP contains any future renderer regression: scripts may
516
+ * only load from this origin (`'self'`) — no inline scripts — while the inline
517
+ * `<style>` block needs `style-src 'unsafe-inline'`; `connect-src` permits the
518
+ * same-origin WebSocket. `frame-ancestors 'none'` + `X-Frame-Options: DENY`
519
+ * stop the tokenized page from being framed (clickjacking); `Referrer-Policy:
520
+ * no-referrer` keeps the `?t` token out of the Referer header on agent-authored
521
+ * outbound links.
522
+ */
523
+ private securityHeaders(): Record<string, string> {
524
+ return {
525
+ 'content-security-policy':
526
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
527
+ "img-src 'self' data: https:; connect-src 'self' ws: wss:; " +
528
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
529
+ 'referrer-policy': 'no-referrer',
530
+ 'x-content-type-options': 'nosniff',
531
+ 'x-frame-options': 'DENY',
532
+ };
533
+ }
534
+
535
+ private async serveFile(res: ServerResponse, name: string, contentType: string): Promise<void> {
536
+ try {
537
+ let buf = await readFile(path.join(PUBLIC_DIR, name));
538
+ if (name === 'index.html') {
539
+ buf = Buffer.from(injectBaseHtml(buf.toString('utf8'), this.basePath), 'utf8');
540
+ }
541
+ res.writeHead(200, { 'content-type': contentType, ...this.securityHeaders() });
542
+ res.end(buf);
543
+ } catch {
544
+ // Defense-in-depth headers are unconditional — they apply to the error
545
+ // response too, so a missing/unreadable bundle never serves a page
546
+ // without the clickjacking / referrer-token protections.
547
+ res.writeHead(500, { 'content-type': 'text/plain', ...this.securityHeaders() });
548
+ res.end('web surface bundle missing — run `pnpm --filter @moxxy/plugin-channel-web build`');
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Record a view frame for replay with a bounded footprint. Named views key
554
+ * by their name (re-render replaces in place) under an LRU bounded at
555
+ * {@link MAX_REPLAY_VIEWS}; unnamed views all collapse into a single
556
+ * latest-wins slot so an unbounded stream of unnamed renders can't leak.
557
+ */
558
+ private rememberView(frame: Extract<ServerFrame, { kind: 'view' }>): void {
559
+ const key = frame.name ?? UNNAMED_VIEW_KEY;
560
+ // Re-insert at the tail to refresh LRU recency on re-render.
561
+ this.views.delete(key);
562
+ this.views.set(key, frame);
563
+ while (this.views.size > MAX_REPLAY_VIEWS) {
564
+ const oldest = this.views.keys().next().value;
565
+ if (oldest === undefined) break;
566
+ this.views.delete(oldest);
567
+ }
568
+ }
569
+
570
+ private onConnection(ws: WebSocket): void {
571
+ this.clients.add(ws);
572
+ ws.on('close', () => this.clients.delete(ws));
573
+ ws.on('message', (data: unknown) => this.onMessage(ws, data));
574
+ this.send(ws, { kind: 'hello' });
575
+ // Replay already-built screens so a browser opening the link AFTER the agent
576
+ // built the app sees it immediately (no "No view yet").
577
+ for (const frame of this.views.values()) this.send(ws, frame);
578
+ }
579
+
580
+ /**
581
+ * Handle a browser → server frame. This is a trust boundary (tunnels put
582
+ * it on the public internet): every frame is schema-validated before any
583
+ * field access, and invalid ones are dropped — a thrown error in a ws
584
+ * 'message' listener escalates to a process-level uncaughtException.
585
+ */
586
+ private onMessage(ws: WebSocket, data: unknown): void {
587
+ const raw = String(data);
588
+ if (raw.length > MAX_FRAME_BYTES) {
589
+ this.dropFrame('oversized frame');
590
+ return;
591
+ }
592
+ let parsed: unknown;
593
+ try {
594
+ parsed = JSON.parse(raw);
595
+ } catch {
596
+ this.dropFrame('invalid JSON');
597
+ return;
598
+ }
599
+ const result = clientFrameSchema.safeParse(parsed);
600
+ if (!result.success) {
601
+ this.dropFrame('schema mismatch');
602
+ return;
603
+ }
604
+ const frame: ClientFrame = result.data;
605
+ if (frame.kind === 'prompt') {
606
+ if (frame.text.trim()) void this.drive(frame.text);
607
+ return;
608
+ }
609
+ if (frame.kind === 'action') {
610
+ if (this.busy) {
611
+ this.send(ws, { kind: 'ack', actionId: frame.actionId, accepted: false, reason: 'busy' });
612
+ return;
613
+ }
614
+ this.send(ws, { kind: 'ack', actionId: frame.actionId, accepted: true });
615
+ void this.drive(actionPrompt(frame.action, frame.formValues));
616
+ }
617
+ }
618
+
619
+ /** Count a dropped inbound frame; warn at most once per window (no log spam). */
620
+ private dropFrame(reason: string): void {
621
+ this.droppedFrames += 1;
622
+ const now = Date.now();
623
+ if (now - this.lastDropWarnAt < DROP_WARN_INTERVAL_MS) return;
624
+ this.lastDropWarnAt = now;
625
+ this.logger?.warn?.('web channel dropped invalid client frame(s)', {
626
+ reason,
627
+ droppedTotal: this.droppedFrames,
628
+ });
629
+ }
630
+
631
+ private async drive(prompt: string): Promise<void> {
632
+ if (!this.session || this.busy) return;
633
+ this.busy = true;
634
+ this.controller = new AbortController();
635
+ try {
636
+ // Rendering happens via the log subscription; we only need to drain the
637
+ // iterator so the turn actually executes.
638
+ for await (const _event of this.session.runTurn(prompt, { signal: this.controller.signal })) {
639
+ void _event;
640
+ }
641
+ } catch (err) {
642
+ this.broadcast({ kind: 'status', turnId: '', phase: 'error', text: err instanceof Error ? err.message : String(err) });
643
+ } finally {
644
+ this.busy = false;
645
+ this.controller = null;
646
+ }
647
+ }
648
+
649
+ private broadcast(frame: ServerFrame): void {
650
+ const s = JSON.stringify(frame);
651
+ for (const ws of this.clients) {
652
+ if (ws.readyState === ws.OPEN) {
653
+ try {
654
+ ws.send(s);
655
+ } catch {
656
+ /* ignore */
657
+ }
658
+ }
659
+ }
660
+ }
661
+
662
+ private send(ws: WebSocket, frame: ServerFrame): void {
663
+ try {
664
+ ws.send(JSON.stringify(frame));
665
+ } catch {
666
+ /* ignore */
667
+ }
668
+ }
669
+ }