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.
- package/LICENSE +19 -0
- package/README.md +27 -0
- package/cordis.patch.yml +29 -0
- package/lib/index.js +3377 -0
- package/lib/invariant.js +26 -0
- package/lib/types/bridge-url.d.ts +27 -0
- package/lib/types/browser-context.d.ts +38 -0
- package/lib/types/dsh-gateway.d.ts +42 -0
- package/lib/types/event-generation.d.ts +56 -0
- package/lib/types/extension-sessions.d.ts +26 -0
- package/lib/types/host-api.d.ts +47 -0
- package/lib/types/image-relay.d.ts +43 -0
- package/lib/types/index.d.ts +158 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/remote-host-api.d.ts +12 -0
- package/lib/types/server.d.ts +166 -0
- package/lib/types/session-deferral.d.ts +33 -0
- package/lib/types/session-history.d.ts +30 -0
- package/lib/types/session-purge.d.ts +55 -0
- package/lib/types/session-workspace.d.ts +37 -0
- package/lib/types/token.d.ts +57 -0
- package/lib/types/tools.d.ts +42 -0
- package/lib/types/vision-selfcheck.d.ts +18 -0
- package/lib/types/vision.d.ts +57 -0
- package/package.json +95 -0
- package/src/bridge-url.ts +57 -0
- package/src/browser-context.ts +102 -0
- package/src/dsh-gateway.ts +66 -0
- package/src/event-generation.ts +385 -0
- package/src/extension-sessions.ts +40 -0
- package/src/host-api.ts +64 -0
- package/src/image-relay.ts +118 -0
- package/src/index.ts +575 -0
- package/src/invariant.ts +33 -0
- package/src/remote-host-api.ts +397 -0
- package/src/server.ts +658 -0
- package/src/session-deferral.ts +296 -0
- package/src/session-history.ts +220 -0
- package/src/session-purge.ts +154 -0
- package/src/session-workspace.ts +147 -0
- package/src/token.ts +100 -0
- package/src/tools.ts +301 -0
- package/src/vision-selfcheck.ts +35 -0
- 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
|
+
}
|