dsh-browser-application 0.37.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 (44) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +27 -0
  3. package/cordis.patch.yml +29 -0
  4. package/lib/index.js +3377 -0
  5. package/lib/invariant.js +26 -0
  6. package/lib/types/bridge-url.d.ts +27 -0
  7. package/lib/types/browser-context.d.ts +38 -0
  8. package/lib/types/dsh-gateway.d.ts +42 -0
  9. package/lib/types/event-generation.d.ts +56 -0
  10. package/lib/types/extension-sessions.d.ts +26 -0
  11. package/lib/types/host-api.d.ts +47 -0
  12. package/lib/types/image-relay.d.ts +43 -0
  13. package/lib/types/index.d.ts +158 -0
  14. package/lib/types/invariant.d.ts +16 -0
  15. package/lib/types/remote-host-api.d.ts +12 -0
  16. package/lib/types/server.d.ts +166 -0
  17. package/lib/types/session-deferral.d.ts +33 -0
  18. package/lib/types/session-history.d.ts +30 -0
  19. package/lib/types/session-purge.d.ts +55 -0
  20. package/lib/types/session-workspace.d.ts +37 -0
  21. package/lib/types/token.d.ts +57 -0
  22. package/lib/types/tools.d.ts +42 -0
  23. package/lib/types/vision-selfcheck.d.ts +18 -0
  24. package/lib/types/vision.d.ts +57 -0
  25. package/package.json +95 -0
  26. package/src/bridge-url.ts +57 -0
  27. package/src/browser-context.ts +102 -0
  28. package/src/dsh-gateway.ts +66 -0
  29. package/src/event-generation.ts +385 -0
  30. package/src/extension-sessions.ts +40 -0
  31. package/src/host-api.ts +64 -0
  32. package/src/image-relay.ts +118 -0
  33. package/src/index.ts +575 -0
  34. package/src/invariant.ts +33 -0
  35. package/src/remote-host-api.ts +397 -0
  36. package/src/server.ts +658 -0
  37. package/src/session-deferral.ts +296 -0
  38. package/src/session-history.ts +220 -0
  39. package/src/session-purge.ts +154 -0
  40. package/src/session-workspace.ts +147 -0
  41. package/src/token.ts +100 -0
  42. package/src/tools.ts +301 -0
  43. package/src/vision-selfcheck.ts +35 -0
  44. package/src/vision.ts +135 -0
package/src/server.ts ADDED
@@ -0,0 +1,658 @@
1
+ /**
2
+ * Bridge WebSocket carrier: token-authenticated connection registry, gateway
3
+ * RPC dispatch, per-connection event pump, and tool-call dispatch to the
4
+ * connected browser extension.
5
+ *
6
+ * The route this server mounts (`/ext/bridge`) lives OUTSIDE the /api trust
7
+ * fence (which only guards the client-connection routes), so the bridge brings
8
+ * its own authentication: a bearer token presented in the `hello` frame within
9
+ * HELLO_TIMEOUT_MS. Host calls terminate at the bridge-owned Host adapter.
10
+ * Methods the /api carrier pins to loopback (`PRIVILEGED_METHODS`)
11
+ * stay loopback-only here regardless of the token, defense in depth for
12
+ * `--host 0.0.0.0` deployments.
13
+ *
14
+ * One active connection at a time: a new authenticated socket replaces the
15
+ * previous one (the old socket is closed and its in-flight tool calls settle
16
+ * as `bridge-closed`).
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { randomUUID } from 'node:crypto'
22
+ import type { IncomingMessage } from 'node:http'
23
+ import type { Duplex } from 'node:stream'
24
+ import { WebSocket, WebSocketServer } from 'ws'
25
+ import type { BrowserHostApi } from './host-api.ts'
26
+ import {
27
+ BRIDGE_INJECT_BROWSER_SNAPSHOT_METHOD,
28
+ BRIDGE_SESSION_PURGE_METHOD,
29
+ HELLO_TIMEOUT_MS,
30
+ PING_INTERVAL_MS,
31
+ parseBridgeFrame,
32
+ type BridgeFrame,
33
+ type BridgeCaps,
34
+ type BridgePolicy,
35
+ type ClientFrame,
36
+ type ToolErrorCode,
37
+ } from '@dsh-browser/protocol'
38
+ import { SessionPurgeError } from './session-purge.ts'
39
+ import type { ImageRelay } from './image-relay.ts'
40
+ import { verifyToken } from './token.ts'
41
+
42
+ /**
43
+ * Gateway methods the /api carrier pins to loopback (mirror of
44
+ * client-connection's PRIVILEGED_METHODS; kept verbatim so the two fences
45
+ * cannot drift). The bridge rejects these for non-loopback remotes even with
46
+ * a valid token.
47
+ */
48
+ const PRIVILEGED_METHODS = new Set([
49
+ 'host.pickDirectory',
50
+ 'host.openPath',
51
+ 'settings.describe',
52
+ 'settings.openDocument',
53
+ 'settings.update',
54
+ 'settings.replace',
55
+ 'settings.mutate',
56
+ 'credentials.describe',
57
+ 'credentials.set',
58
+ 'credentials.unset',
59
+ ])
60
+
61
+ /** Session mutations whose WebSocket arrival order is behaviorally significant. */
62
+ const ORDERED_SESSION_METHODS = new Set([
63
+ BRIDGE_INJECT_BROWSER_SNAPSHOT_METHOD,
64
+ 'session.prompt',
65
+ 'session.cancel',
66
+ ])
67
+
68
+ /** Loopback IPv4/IPv6 literals (IPv4-mapped included). Exported for tests and reuse. */
69
+ export function isLoopbackAddress(address: string | undefined): boolean {
70
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
71
+ }
72
+
73
+ /** Error thrown by requestTool; the tool registry turns it into an isError result. */
74
+ export class BridgeToolError extends Error {
75
+ constructor(
76
+ readonly code: ToolErrorCode,
77
+ message: string,
78
+ ) {
79
+ super(message)
80
+ this.name = 'BridgeToolError'
81
+ }
82
+ }
83
+
84
+ /** Dependencies the bridge needs from the host. */
85
+ export interface BridgeServerDeps {
86
+ /** Bearer token the extension must present in `hello`. */
87
+ token: string
88
+ /** Active dsh Host adapter used for unary calls, events, and waterfalls. */
89
+ api: BrowserHostApi
90
+ /** Default per-tool-call timeout in ms. */
91
+ toolTimeoutMs: number
92
+ /** Capabilities to echo in `hello.ok` (negotiated snapshot budgets). */
93
+ caps: BridgeCaps
94
+ /** What the desktop app allows, sent alongside the caps in `hello.ok`. */
95
+ policy: BridgePolicy
96
+ /**
97
+ * Image recognition on the extension's behalf. Absent when no vision model is
98
+ * configured, which the extension learns from `hello.ok`'s policy.
99
+ */
100
+ imageRelay?: ImageRelay
101
+ /**
102
+ * Why no relay is available, in words the user can act on.
103
+ *
104
+ * Sent in `hello.ok` so the extension can put it in front of whoever asked for an
105
+ * image, instead of reporting only that recognition is unavailable — which leaves
106
+ * the reader with a dead end and no next step.
107
+ */
108
+ visionUnavailableReason?: string
109
+ /** Seed a followed-page snapshot into a live or deferred Agent session. */
110
+ injectBrowserSnapshot: (sessionId: string, snapshot: string) => void | Promise<void>
111
+ /**
112
+ * Permanently delete one session's durable storage. Callers archive the
113
+ * session through the gateway first; this only removes files.
114
+ */
115
+ purgeSession: (sessionId: string) => Promise<void>
116
+ /**
117
+ * Test seam: force the remote address seen by the privilege gate. The
118
+ * sandbox cannot bind arbitrary loopback literals, so the non-loopback
119
+ * branch is exercised through this override; production never sets it.
120
+ */
121
+ remoteAddressOverride?: string
122
+ /** Seconds a fresh socket may present `hello`; defaults to HELLO_TIMEOUT_MS. */
123
+ helloTimeoutMs?: number
124
+ /** Server ping cadence; defaults to PING_INTERVAL_MS. */
125
+ pingIntervalMs?: number
126
+ }
127
+
128
+ /** One in-flight tool call awaiting the extension's `tool.result`. */
129
+ interface PendingTool {
130
+ resolve: (result: unknown) => void
131
+ reject: (error: BridgeToolError) => void
132
+ timer: NodeJS.Timeout
133
+ }
134
+
135
+ /** A socket that passed authentication and owns the single active slot. */
136
+ interface ReadyConnection {
137
+ ws: WebSocket
138
+ /** Remote address captured at upgrade time (loopback gate for privileged methods). */
139
+ remoteAddress: string | undefined
140
+ abort: AbortController
141
+ pump: Promise<void>
142
+ ping: NodeJS.Timeout
143
+ }
144
+
145
+ function sendFrame(ws: WebSocket, frame: BridgeFrame): void {
146
+ /* v8 ignore next -- teardown race: the socket can die between a pump's
147
+ readiness check and this write; the guard refuses writes on dead sockets */
148
+ if (ws.readyState !== WebSocket.OPEN) return
149
+ ws.send(JSON.stringify(frame))
150
+ }
151
+
152
+ /**
153
+ * Decode one ws message payload to text. Exported so all three delivery
154
+ * shapes (fragmented buffer list, Buffer, ArrayBuffer) are unit-testable
155
+ * directly — node ws only ever delivers Buffers in practice.
156
+ * @param data - ws message payload.
157
+ * @returns the decoded UTF-8 text.
158
+ */
159
+ export function messageToText(data: Buffer | ArrayBuffer | Buffer[]): string {
160
+ if (Array.isArray(data)) return Buffer.concat(data).toString('utf8')
161
+ if (Buffer.isBuffer(data)) return data.toString('utf8')
162
+ return Buffer.from(data).toString('utf8')
163
+ }
164
+
165
+ /**
166
+ * Token-authenticated bridge server. Construct once per plugin instance;
167
+ * dispose with {@link close}.
168
+ */
169
+ export class BridgeServer {
170
+ private readonly wss = new WebSocketServer({ noServer: true })
171
+ private current: ReadyConnection | null = null
172
+ private readonly pendingTools = new Map<string, PendingTool>()
173
+ private readonly orderedSessionRpcs = new Map<string, Promise<void>>()
174
+ private closed = false
175
+
176
+ constructor(private readonly deps: BridgeServerDeps) {}
177
+
178
+ /**
179
+ * Handle one HTTP upgrade for the bridge path.
180
+ * @param req - upgrade request (carries the client's remote address).
181
+ * @param socket - raw socket transferred by the HTTP server.
182
+ * @param head - bytes already read after the upgrade headers.
183
+ */
184
+ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
185
+ const remote = this.deps.remoteAddressOverride ?? req.socket.remoteAddress
186
+ const origin = req.headers.origin
187
+ this.wss.handleUpgrade(req, socket, head, (ws) => { this.attach(ws, remote, origin) })
188
+ }
189
+
190
+ /**
191
+ * Request one browser action from the connected extension.
192
+ * @param name - tool name (also the wire action name).
193
+ * @param args - validated tool arguments.
194
+ * @param signal - caller cancellation (abort settles the call as cancelled).
195
+ * @param timeoutMs - per-call budget; defaults to the plugin config value.
196
+ * @param sessionId - optional owning Agent session for approval continuity.
197
+ * @returns the extension's action result.
198
+ * @throws BridgeToolError when no extension is connected, the call times
199
+ * out, is cancelled, or the extension reports a failure.
200
+ */
201
+ requestTool(
202
+ name: string,
203
+ args: Record<string, unknown>,
204
+ signal: AbortSignal,
205
+ timeoutMs: number = this.deps.toolTimeoutMs,
206
+ sessionId?: string,
207
+ ): Promise<unknown> {
208
+ const conn = this.current
209
+ if (conn === null) {
210
+ throw new BridgeToolError('bridge-closed', 'no browser extension is connected to the bridge')
211
+ }
212
+ // A caller that already aborted must not dispatch: the abort listener
213
+ // below does not replay for pre-aborted signals, so the call would be
214
+ // sent to the extension and executed despite the cancellation.
215
+ if (signal.aborted) {
216
+ throw new BridgeToolError('bridge-closed', 'tool call cancelled before dispatch')
217
+ }
218
+ const id = randomUUID()
219
+ const expiresAt = Date.now() + timeoutMs
220
+ return new Promise<unknown>((resolve, reject) => {
221
+ let timer: NodeJS.Timeout
222
+ const settle = (error: BridgeToolError): void => {
223
+ clearTimeout(timer)
224
+ this.pendingTools.delete(id)
225
+ signal.removeEventListener('abort', onAbort)
226
+ reject(error)
227
+ }
228
+ const cancel = (error: BridgeToolError): void => {
229
+ // The extension may be paused on a user approval after the caller has
230
+ // stopped waiting. Withdraw that approval before settling locally so
231
+ // a late click cannot execute an expired action.
232
+ sendFrame(conn.ws, { t: 'tool.cancel', id })
233
+ settle(error)
234
+ }
235
+ const onAbort = (): void => {
236
+ // A caller that stops waiting is a timeout, not a missing connection:
237
+ // reporting `bridge-closed` here made "the extension never answered"
238
+ // indistinguishable from "there was never a connection", which is exactly
239
+ // the distinction needed to diagnose a browser that is connected but idle.
240
+ cancel(new BridgeToolError('timeout', 'tool call cancelled before the extension answered'))
241
+ }
242
+ timer = setTimeout(() => {
243
+ cancel(new BridgeToolError('timeout', `browser action "${name}" timed out after ${timeoutMs}ms`))
244
+ }, timeoutMs)
245
+ signal.addEventListener('abort', onAbort, { once: true })
246
+ this.pendingTools.set(id, { resolve, reject, timer })
247
+ conn.ws.send(JSON.stringify({
248
+ t: 'tool.call',
249
+ id,
250
+ name,
251
+ args,
252
+ expiresAt,
253
+ ...(sessionId === undefined ? {} : { sessionId }),
254
+ } satisfies BridgeFrame), (error) => {
255
+ /* v8 ignore next -- teardown race: when the write fails, the socket's
256
+ close handler settles the same call with the same code; the callback
257
+ path is a defensive second settle, covered via the close path */
258
+ if (error != null) {
259
+ settle(new BridgeToolError('bridge-closed', `bridge socket failed before delivery: ${error.message}`))
260
+ }
261
+ })
262
+ })
263
+ }
264
+
265
+ /**
266
+ * Terminate the server: close the acceptor, drop all sockets, reject all
267
+ * in-flight tool calls.
268
+ * @returns a promise resolving after the acceptor and all pumps stop.
269
+ */
270
+ async close(): Promise<void> {
271
+ // Idempotent: a second close must not touch the acceptor (ws throws
272
+ // "The server is not running" when closing an already-closed server).
273
+ if (this.closed) return
274
+ this.closed = true
275
+ // Capture the live pump BEFORE replaceConnection nulls the connection.
276
+ const pumps = this.current === null ? [] : [this.current.pump]
277
+ this.replaceConnection()
278
+ for (const socket of this.wss.clients) socket.terminate()
279
+ this.current = null
280
+ await new Promise<void>((resolve, reject) => {
281
+ this.wss.close((error) => {
282
+ /* v8 ignore next -- acceptor close cannot fail: close() is idempotent
283
+ and the noServer acceptor only reports teardown of already-terminated clients */
284
+ if (error === undefined) resolve()
285
+ /* v8 ignore next -- same unreachable arm */
286
+ else reject(error)
287
+ })
288
+ })
289
+ await Promise.all(pumps)
290
+ }
291
+
292
+ /** @returns whether an authenticated extension is currently connected. */
293
+ hasConnection(): boolean {
294
+ return this.current !== null
295
+ }
296
+
297
+ private attach(ws: WebSocket, remoteAddress: string | undefined, origin: string | undefined): void {
298
+ let helloTimer: NodeJS.Timeout | undefined = setTimeout(() => {
299
+ ws.close(4001, 'hello timeout')
300
+ }, this.deps.helloTimeoutMs ?? HELLO_TIMEOUT_MS)
301
+
302
+ const onMessage = (data: Buffer | ArrayBuffer | Buffer[]): void => {
303
+ const text = messageToText(data)
304
+ const frame = parseBridgeFrame(text)
305
+ if (frame === undefined) {
306
+ ws.close(1008, 'unparseable frame')
307
+ return
308
+ }
309
+ if (helloTimer !== undefined) {
310
+ // Pending state: only `hello` is legal.
311
+ if (frame.t !== 'hello') {
312
+ ws.close(1008, 'hello first')
313
+ return
314
+ }
315
+ // Zero-config local mode: loopback sockets skip the token (the
316
+ // extension auto-discovers the bridge and connects without setup).
317
+ // WebSockets have no same-origin policy, so a malicious page could
318
+ // open a cross-origin socket to 127.0.0.1 with a loopback remote —
319
+ // the loopback shortcut therefore requires a chrome-extension://
320
+ // Origin (only extension contexts can present one; pages cannot
321
+ // forge the header). Firefox moz-extension:// origins contain a
322
+ // per-install UUID rather than the manifest's stable Gecko ID, so
323
+ // they are not an identity boundary and must present the bearer token.
324
+ // Non-loopback remotes must also present the bearer token.
325
+ const loopbackNoToken = isLoopbackAddress(remoteAddress)
326
+ && typeof origin === 'string'
327
+ && origin.startsWith('chrome-extension://')
328
+ if (!loopbackNoToken && !verifyToken(this.deps.token, frame.token)) {
329
+ ws.close(4002, 'bad token')
330
+ return
331
+ }
332
+ clearTimeout(helloTimer)
333
+ helloTimer = undefined
334
+ this.promote(ws, remoteAddress)
335
+ return
336
+ }
337
+ this.handleReadyFrame(frame)
338
+ }
339
+ const onClose = (): void => {
340
+ if (helloTimer !== undefined) clearTimeout(helloTimer)
341
+ if (this.current !== null && this.current.ws === ws) this.replaceConnection()
342
+ }
343
+ ws.on('message', onMessage)
344
+ ws.once('close', onClose)
345
+ ws.once('error', onClose)
346
+ }
347
+
348
+ /** Promote an authenticated socket to the single active slot. */
349
+ private promote(ws: WebSocket, remoteAddress: string | undefined): void {
350
+ this.replaceConnection()
351
+ const abort = new AbortController()
352
+ const ping = setInterval(() => { sendFrame(ws, { t: 'ping' }) }, this.deps.pingIntervalMs ?? PING_INTERVAL_MS)
353
+ const pump = (async () => {
354
+ try {
355
+ for await (const frame of this.deps.api.events(abort.signal)) {
356
+ if (ws.readyState !== WebSocket.OPEN) break
357
+ sendFrame(ws, {
358
+ t: 'event',
359
+ frame,
360
+ })
361
+ }
362
+ } catch (error: unknown) {
363
+ if (!abort.signal.aborted && ws.readyState === WebSocket.OPEN) {
364
+ sendFrame(ws, { t: 'error', code: 'stream-failed', message: String(error) })
365
+ // An authenticated socket without its Remote streams is unusable but
366
+ // otherwise appears healthy to the extension. Closing the generation
367
+ // activates its bounded reconnect loop and rebuilds every follower.
368
+ ws.close(1011, 'event stream failed')
369
+ }
370
+ }
371
+ })()
372
+ this.current = { ws, remoteAddress, abort, pump, ping }
373
+ // The relay's presence is the single source of truth for this flag, so the
374
+ // plugin cannot advertise a capability it did not wire up.
375
+ sendFrame(ws, {
376
+ t: 'hello.ok',
377
+ caps: this.deps.caps,
378
+ policy: {
379
+ ...this.deps.policy,
380
+ imageRecognition: this.deps.imageRelay?.available === true,
381
+ // Carried rather than logged: it is the user's only way to learn what to
382
+ // fix, and the extension is where they will read it.
383
+ ...this.deps.imageRelay?.available === true || this.deps.visionUnavailableReason === undefined
384
+ ? {}
385
+ : { imageRecognitionHint: this.deps.visionUnavailableReason },
386
+ },
387
+ })
388
+ ws.once('close', () => {
389
+ clearInterval(ping)
390
+ abort.abort()
391
+ })
392
+ }
393
+
394
+ private handleReadyFrame(frame: BridgeFrame): void {
395
+ switch (frame.t) {
396
+ case 'rpc':
397
+ this.routeRpc(frame)
398
+ break
399
+ case 'respond':
400
+ void this.handleRespond(frame)
401
+ break
402
+ case 'tool.result':
403
+ this.settleTool(frame.id, frame.ok, frame.ok ? frame.result : frame.error)
404
+ break
405
+ case 'image.call':
406
+ void this.handleImageCall(frame)
407
+ break
408
+ case 'pong':
409
+ case 'hello':
410
+ case 'hello.ok':
411
+ case 'rpc.result':
412
+ case 'respond.result':
413
+ case 'event':
414
+ case 'tool.call':
415
+ case 'tool.cancel':
416
+ case 'ping':
417
+ case 'error':
418
+ // Protocol violations and unsolicited server-side shapes are ignored;
419
+ // the extension is the only sender on this channel.
420
+ break
421
+ }
422
+ }
423
+
424
+ /** Recognition requests in flight; the extension bounds its side as well. */
425
+ private imageCallsInFlight = 0
426
+ private readonly maxImageCalls = 4
427
+
428
+ /**
429
+ * Serve one image-recognition request from the extension.
430
+ *
431
+ * A failure is answered with a frame rather than a throw, because the extension
432
+ * records it in the manifest: the model must be told "there is an image here
433
+ * and it could not be read", not shown nothing at all.
434
+ */
435
+ private async handleImageCall(frame: Extract<ClientFrame, { t: 'image.call' }>): Promise<void> {
436
+ const ws = this.current?.ws
437
+ if (ws === undefined) return
438
+ const relay = this.deps.imageRelay
439
+ if (relay === undefined) {
440
+ sendFrame(ws, {
441
+ t: 'image.result',
442
+ id: frame.id,
443
+ ok: false,
444
+ error: { code: 'no-vision', message: 'image recognition is not configured on the desktop' },
445
+ })
446
+ return
447
+ }
448
+ if (this.imageCallsInFlight >= this.maxImageCalls) {
449
+ sendFrame(ws, {
450
+ t: 'image.result',
451
+ id: frame.id,
452
+ ok: false,
453
+ error: { code: 'busy', message: 'too many recognition requests in flight' },
454
+ })
455
+ return
456
+ }
457
+
458
+ this.imageCallsInFlight += 1
459
+ try {
460
+ const result = await relay.recognize(frame.request, frame.source)
461
+ if (ws.readyState !== WebSocket.OPEN) return
462
+ sendFrame(ws, result.ok
463
+ ? { t: 'image.result', id: frame.id, ok: true, desc: result.desc }
464
+ : { t: 'image.result', id: frame.id, ok: false, error: { code: result.code, message: result.message } })
465
+ } finally {
466
+ this.imageCallsInFlight -= 1
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Preserve prompt/cancel arrival order per session. In particular, the
472
+ * first prompt may still be materializing a provisional session; its cancel
473
+ * must not reach the gateway until that admission has completed.
474
+ */
475
+ private routeRpc(frame: Extract<ClientFrame, { t: 'rpc' }>): void {
476
+ const sessionId = orderedSessionId(frame)
477
+ if (sessionId === undefined) {
478
+ void this.handleRpc(frame)
479
+ return
480
+ }
481
+ const previous = this.orderedSessionRpcs.get(sessionId) ?? Promise.resolve()
482
+ const task = previous.then(
483
+ () => this.handleRpc(frame),
484
+ () => this.handleRpc(frame),
485
+ )
486
+ this.orderedSessionRpcs.set(sessionId, task)
487
+ const clear = (): void => {
488
+ if (this.orderedSessionRpcs.get(sessionId) === task) this.orderedSessionRpcs.delete(sessionId)
489
+ }
490
+ void task.then(clear, clear)
491
+ }
492
+
493
+ private async handleRpc(frame: Extract<ClientFrame, { t: 'rpc' }>): Promise<void> {
494
+ const conn = this.current
495
+ /* v8 ignore next -- replacement race: a frame can land between a socket
496
+ replacement and the next promotion; the re-check keeps the handler total */
497
+ if (conn === null) return
498
+ const forbidden = PRIVILEGED_METHODS.has(frame.method) && !isLoopbackAddress(conn.remoteAddress)
499
+ if (forbidden) {
500
+ sendFrame(conn.ws, { t: 'rpc.result', id: frame.id, ok: false, error: { code: 'forbidden', message: 'method is loopback-only' } })
501
+ return
502
+ }
503
+ if (frame.method === BRIDGE_INJECT_BROWSER_SNAPSHOT_METHOD) {
504
+ const payload = browserSnapshotPayload(frame.payload)
505
+ if (payload === undefined) {
506
+ sendFrame(conn.ws, {
507
+ t: 'rpc.result',
508
+ id: frame.id,
509
+ ok: false,
510
+ error: { code: 'bad-request', message: 'sessionId and snapshot must be non-empty strings' },
511
+ })
512
+ return
513
+ }
514
+ try {
515
+ await this.deps.injectBrowserSnapshot(payload.sessionId, payload.snapshot)
516
+ sendFrame(conn.ws, { t: 'rpc.result', id: frame.id, ok: true, result: { accepted: true } })
517
+ } catch (error: unknown) {
518
+ sendFrame(conn.ws, {
519
+ t: 'rpc.result',
520
+ id: frame.id,
521
+ ok: false,
522
+ error: { code: 'internal', message: String(error) },
523
+ })
524
+ }
525
+ return
526
+ }
527
+ if (frame.method === BRIDGE_SESSION_PURGE_METHOD) {
528
+ const sessionId = purgeSessionPayload(frame.payload)
529
+ if (sessionId === undefined) {
530
+ sendFrame(conn.ws, {
531
+ t: 'rpc.result',
532
+ id: frame.id,
533
+ ok: false,
534
+ error: { code: 'bad-request', message: 'sessionId must be a non-empty string' },
535
+ })
536
+ return
537
+ }
538
+ try {
539
+ await this.deps.purgeSession(sessionId)
540
+ sendFrame(conn.ws, { t: 'rpc.result', id: frame.id, ok: true, result: { purged: true } })
541
+ } catch (error: unknown) {
542
+ const code = error instanceof SessionPurgeError ? error.code : 'internal'
543
+ const message = error instanceof Error ? error.message : String(error)
544
+ sendFrame(conn.ws, { t: 'rpc.result', id: frame.id, ok: false, error: { code, message } })
545
+ }
546
+ return
547
+ }
548
+ try {
549
+ const result = await this.deps.api.call({
550
+ rpcId: frame.id,
551
+ method: frame.method,
552
+ payload: frame.payload,
553
+ signal: conn.abort.signal,
554
+ })
555
+ sendFrame(conn.ws, {
556
+ t: 'rpc.result',
557
+ id: frame.id,
558
+ ok: true,
559
+ result: { type: 'server-response', rpcId: frame.id, result },
560
+ })
561
+ } catch (error: unknown) {
562
+ sendFrame(conn.ws, { t: 'rpc.result', id: frame.id, ok: false, error: { code: 'internal', message: String(error) } })
563
+ }
564
+ }
565
+
566
+ /** Relay a pending Host waterfall response through the active adapter. */
567
+ private async handleRespond(frame: Extract<ClientFrame, { t: 'respond' }>): Promise<void> {
568
+ const conn = this.current
569
+ /* v8 ignore next -- replacement race; a closed socket simply drops the receipt */
570
+ if (conn === null) return
571
+ try {
572
+ const result = await this.deps.api.respond(frame.rpcId, frame.result, conn.abort.signal)
573
+ sendFrame(conn.ws, { t: 'respond.result', id: frame.id, ok: true, result })
574
+ } catch (error: unknown) {
575
+ sendFrame(conn.ws, { t: 'respond.result', id: frame.id, ok: false, error: { code: 'internal', message: String(error) } })
576
+ }
577
+ }
578
+
579
+ private settleTool(id: string, ok: boolean, payload: unknown): void {
580
+ const pending = this.pendingTools.get(id)
581
+ if (pending === undefined) return
582
+ clearTimeout(pending.timer)
583
+ this.pendingTools.delete(id)
584
+ if (ok) pending.resolve(payload)
585
+ else pending.reject(new BridgeToolError(payloadCode(payload), payloadMessage(payload)))
586
+ }
587
+
588
+ /** Close the current connection (if any) and settle its in-flight calls. */
589
+ private replaceConnection(): void {
590
+ const conn = this.current
591
+ if (conn === null) return
592
+ this.current = null
593
+ clearInterval(conn.ping)
594
+ conn.abort.abort()
595
+ if (conn.ws.readyState === WebSocket.OPEN || conn.ws.readyState === WebSocket.CONNECTING) {
596
+ conn.ws.close(4000, 'replaced')
597
+ }
598
+ for (const [id, pending] of this.pendingTools) {
599
+ clearTimeout(pending.timer)
600
+ this.pendingTools.delete(id)
601
+ pending.reject(new BridgeToolError('bridge-closed', 'the extension connection was replaced'))
602
+ }
603
+ }
604
+ }
605
+
606
+ function browserSnapshotPayload(payload: unknown): { sessionId: string; snapshot: string } | undefined {
607
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return undefined
608
+ const { sessionId, snapshot } = payload as Record<string, unknown>
609
+ if (typeof sessionId !== 'string' || sessionId.trim() === '') return undefined
610
+ if (typeof snapshot !== 'string' || snapshot.trim() === '') return undefined
611
+ return { sessionId, snapshot }
612
+ }
613
+
614
+ function purgeSessionPayload(payload: unknown): string | undefined {
615
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return undefined
616
+ const { sessionId } = payload as Record<string, unknown>
617
+ if (typeof sessionId !== 'string' || sessionId.trim() === '') return undefined
618
+ return sessionId
619
+ }
620
+
621
+ function orderedSessionId(frame: Extract<ClientFrame, { t: 'rpc' }>): string | undefined {
622
+ if (!ORDERED_SESSION_METHODS.has(frame.method)) return undefined
623
+ if (typeof frame.payload !== 'object' || frame.payload === null || Array.isArray(frame.payload)) return undefined
624
+ const sessionId = (frame.payload as Record<string, unknown>).sessionId
625
+ return typeof sessionId === 'string' ? sessionId : undefined
626
+ }
627
+
628
+ /**
629
+ * Tool error payload → stable code. The wire parser enforces string fields,
630
+ * so the fallback branches are parser-gated; exported so the fallback
631
+ * contract is unit-testable directly.
632
+ * @param payload - extension-reported error payload.
633
+ * @returns the stable error code.
634
+ */
635
+ export function payloadCode(payload: unknown): ToolErrorCode {
636
+ if (typeof payload === 'object' && payload !== null) {
637
+ const code = (payload as { code?: unknown }).code
638
+ if (typeof code === 'string') return code as ToolErrorCode
639
+ return 'internal'
640
+ }
641
+ return 'internal'
642
+ }
643
+
644
+ /**
645
+ * Tool error payload → message. The wire parser enforces string fields, so
646
+ * the fallback branches are parser-gated; exported so the fallback contract
647
+ * is unit-testable directly.
648
+ * @param payload - extension-reported error payload.
649
+ * @returns the human-readable message.
650
+ */
651
+ export function payloadMessage(payload: unknown): string {
652
+ if (typeof payload === 'object' && payload !== null) {
653
+ const message = (payload as { message?: unknown }).message
654
+ if (typeof message === 'string' && message.length > 0) return message
655
+ return 'browser action failed'
656
+ }
657
+ return 'browser action failed'
658
+ }