dsh-sidebar-browser-agent 0.0.0-stage → 0.2.6
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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +226 -2
- package/cordis.patch.yml +8 -0
- package/icon.svg +8 -0
- package/lib/client.js +1651 -0
- package/lib/entry-0.2.6.js +1181 -0
- package/lib/index.js +1181 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +61 -4
- package/test/client-harness.mjs +687 -0
- package/test/host-harness.mjs +548 -0
- package/test/run.mjs +22 -0
- package/vendor/NOTICE.md +15 -0
- package/vendor/cosmokit/LICENSE +21 -0
- package/vendor/cosmokit/index.js +418 -0
- package/vendor/schemastery/LICENSE +21 -0
- package/vendor/schemastery/index.mjs +635 -0
|
@@ -0,0 +1,1181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-sidebar-browser-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* 1. Register two model-facing tools:
|
|
6
|
+
* - `sidebar_browser` — tab management of the native right-sidebar browser;
|
|
7
|
+
* - `sidebar_page` — page content and interaction in a native browser tab.
|
|
8
|
+
* 2. Run the HTTP long-poll bridge carrying commands to the Web page half and
|
|
9
|
+
* answers back.
|
|
10
|
+
* 3. Turn a screenshot's base64 PNG into a durable image attachment so the
|
|
11
|
+
* model receives the actual image as a content block.
|
|
12
|
+
*
|
|
13
|
+
* The page half drives the native Electron guest (`executeJavaScript`,
|
|
14
|
+
* `capturePage`, `sendInputEvent`); this half never renders a page itself.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
import z from '../vendor/schemastery/index.mjs'
|
|
18
|
+
|
|
19
|
+
/** Plugin identity used by the bundle patch row. */
|
|
20
|
+
export const name = 'dsh-sidebar-browser-agent'
|
|
21
|
+
|
|
22
|
+
/** Plugin version reported by diagnostics; keep in sync with package.json. */
|
|
23
|
+
export const PLUGIN_VERSION = '0.2.6'
|
|
24
|
+
|
|
25
|
+
/** Services that must exist before this plugin mounts. */
|
|
26
|
+
export const inject = ['webServer', 'connection', 'tools']
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration schema shown by the Plugin Manager.
|
|
30
|
+
*
|
|
31
|
+
* This is a real native Schemastery schema (vendored, so a linked install
|
|
32
|
+
* needs no module resolution from the profile): the Loader validates and
|
|
33
|
+
* applies defaults, and Plugin Manager renders the fields for editing.
|
|
34
|
+
*/
|
|
35
|
+
export const Config = z.object({
|
|
36
|
+
enabled: z.boolean().default(true).volatile().description('Register the sidebar_browser and sidebar_page agent tools.'),
|
|
37
|
+
allowedHosts: z.array(z.string()).default([]).volatile().description('Limit "open" and "navigate" to these hosts. An entry matches that exact host or a subdomain suffix; an empty list allows every HTTP(S) host.'),
|
|
38
|
+
allowEval: z.boolean().default(true).volatile().description('Allow sidebar_page action "eval" (arbitrary JavaScript in the page).'),
|
|
39
|
+
allowNativeInput: z.boolean().default(true).volatile().description('Allow sidebar_page click, type and press actions (native mouse and keyboard input).'),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** Routes the page half talks to. */
|
|
43
|
+
const ROUTES = {
|
|
44
|
+
poll: '/plugin/sidebar-browser-agent/poll',
|
|
45
|
+
result: '/plugin/sidebar-browser-agent/result',
|
|
46
|
+
report: '/plugin/sidebar-browser-agent/report',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How long one poll request is held open while no command is pending. */
|
|
50
|
+
const POLL_HOLD_MS = 15_000
|
|
51
|
+
/** How long a tool call waits for the page's answer before failing. */
|
|
52
|
+
const RESULT_TIMEOUT_MS = 30_000
|
|
53
|
+
/** How long a tool call waits for a connected poller when none is parked. */
|
|
54
|
+
const CLIENT_WAIT_MS = 2_500
|
|
55
|
+
/** A client whose last poll is older than this is treated as disconnected. */
|
|
56
|
+
const CLIENT_STALE_MS = 5_000
|
|
57
|
+
/** Upper bound for a bridge request body (screenshots travel through it). */
|
|
58
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
59
|
+
/** Hard cap on one screenshot payload. */
|
|
60
|
+
const MAX_SCREENSHOT_BASE64 = 12_000_000
|
|
61
|
+
|
|
62
|
+
/** Tab-management actions of `sidebar_browser`. */
|
|
63
|
+
const TAB_ACTIONS = ['status', 'list', 'open', 'navigate', 'reload', 'focus', 'close']
|
|
64
|
+
/** Actions that need an HTTP(S) address. */
|
|
65
|
+
const URL_ACTIONS = new Set(['open', 'navigate'])
|
|
66
|
+
/** Actions that need an existing tab id. */
|
|
67
|
+
const TAB_ID_ACTIONS = new Set(['navigate', 'reload', 'focus', 'close'])
|
|
68
|
+
/** Page-content actions of `sidebar_page`. */
|
|
69
|
+
const PAGE_ACTIONS = ['read', 'click', 'type', 'press', 'scroll', 'eval', 'wait', 'screenshot']
|
|
70
|
+
/** Read modes. */
|
|
71
|
+
const READ_MODES = ['text', 'interactive', 'html']
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write one JSON response unless the peer already went away.
|
|
75
|
+
* @param res - Node response owned by the route handler.
|
|
76
|
+
* @param status - HTTP status code.
|
|
77
|
+
* @param payload - JSON-serializable body.
|
|
78
|
+
*/
|
|
79
|
+
function writeJson(res, status, payload) {
|
|
80
|
+
if (res.writableEnded || res.destroyed) return
|
|
81
|
+
const body = JSON.stringify(payload)
|
|
82
|
+
res.writeHead(status, {
|
|
83
|
+
'content-type': 'application/json; charset=utf-8',
|
|
84
|
+
'content-length': Buffer.byteLength(body),
|
|
85
|
+
'cache-control': 'no-store',
|
|
86
|
+
})
|
|
87
|
+
res.end(body)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read a bounded request body as UTF-8 text.
|
|
92
|
+
* @param req - Node request.
|
|
93
|
+
* @param limit - maximum accepted byte count.
|
|
94
|
+
* @returns the decoded body.
|
|
95
|
+
*/
|
|
96
|
+
function readBody(req, limit) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const chunks = []
|
|
99
|
+
let size = 0
|
|
100
|
+
req.on('data', (chunk) => {
|
|
101
|
+
size += chunk.length
|
|
102
|
+
if (size > limit) {
|
|
103
|
+
reject(new Error('request body is too large'))
|
|
104
|
+
req.destroy()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
chunks.push(chunk)
|
|
108
|
+
})
|
|
109
|
+
req.on('end', () => {
|
|
110
|
+
resolve(Buffer.concat(chunks).toString('utf8'))
|
|
111
|
+
})
|
|
112
|
+
req.on('error', reject)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the error a cancellation raises from a tool call.
|
|
118
|
+
* @param reason - abort reason when the caller supplied one.
|
|
119
|
+
* @returns an Error carrying the usual aborted message.
|
|
120
|
+
*/
|
|
121
|
+
function abortError(reason) {
|
|
122
|
+
const error = new Error('the tool call was aborted')
|
|
123
|
+
error.name = 'AbortError'
|
|
124
|
+
if (reason !== undefined) error.cause = reason
|
|
125
|
+
return error
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sleep that also settles when the caller aborts.
|
|
130
|
+
* @param ms - milliseconds to wait.
|
|
131
|
+
* @param signal - optional caller signal.
|
|
132
|
+
*/
|
|
133
|
+
function delay(ms, signal) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const finish = () => {
|
|
136
|
+
clearTimeout(timer)
|
|
137
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
138
|
+
}
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
finish()
|
|
141
|
+
resolve()
|
|
142
|
+
}, ms)
|
|
143
|
+
const onAbort = () => {
|
|
144
|
+
finish()
|
|
145
|
+
reject(abortError(signal.reason))
|
|
146
|
+
}
|
|
147
|
+
if (signal !== undefined) {
|
|
148
|
+
if (signal.aborted) {
|
|
149
|
+
finish()
|
|
150
|
+
reject(abortError(signal.reason))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validate and normalize one HTTP(S) address.
|
|
160
|
+
* @param raw - model-supplied address.
|
|
161
|
+
* @param action - action asking for it, used in the failure text.
|
|
162
|
+
* @returns the normalized absolute URL string.
|
|
163
|
+
*/
|
|
164
|
+
function normalizeHttpUrl(raw, action) {
|
|
165
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
166
|
+
throw new Error(`action "${action}" requires an http:// or https:// url`)
|
|
167
|
+
}
|
|
168
|
+
let parsed
|
|
169
|
+
try {
|
|
170
|
+
parsed = new URL(raw.trim())
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(`"${raw}" is not a valid URL`)
|
|
173
|
+
}
|
|
174
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
175
|
+
throw new Error('the sidebar browser only opens http:// and https:// URLs')
|
|
176
|
+
}
|
|
177
|
+
return parsed.toString()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Normalize the validated plugin configuration into the exact shape used at runtime.
|
|
182
|
+
* @param config - config object handed to apply (defaults already applied by the loader).
|
|
183
|
+
* @returns the resolved switches.
|
|
184
|
+
*/
|
|
185
|
+
function fieldValue(field, fallback) {
|
|
186
|
+
if (field !== null && typeof field === 'object' && typeof field.get === 'function') return field.get()
|
|
187
|
+
if (field === undefined) return fallback
|
|
188
|
+
return field
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the switch values currently in force.
|
|
193
|
+
*
|
|
194
|
+
* The declaration marks every field `.volatile()`, so the Loader hands the
|
|
195
|
+
* plugin stable references; editing the row in Plugin Manager (or the profile
|
|
196
|
+
* patch) updates those references without restarting the plugin.
|
|
197
|
+
* @param config - raw plugin config from `apply` (volatile fields are references).
|
|
198
|
+
* @returns the resolved switches.
|
|
199
|
+
*/
|
|
200
|
+
function liveConfig(config) {
|
|
201
|
+
const raw = config === null || typeof config !== 'object' ? {} : config
|
|
202
|
+
const hosts = fieldValue(raw.allowedHosts, [])
|
|
203
|
+
return {
|
|
204
|
+
enabled: fieldValue(raw.enabled, true) !== false,
|
|
205
|
+
allowEval: fieldValue(raw.allowEval, true) !== false,
|
|
206
|
+
allowNativeInput: fieldValue(raw.allowNativeInput, true) !== false,
|
|
207
|
+
allowedHosts: Array.isArray(hosts)
|
|
208
|
+
? hosts.filter((entry) => typeof entry === 'string' && entry.trim() !== '').map((entry) => entry.trim().toLowerCase())
|
|
209
|
+
: [],
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Enforce the configured host allowlist for one navigation.
|
|
215
|
+
* @param url - normalized absolute HTTP(S) URL.
|
|
216
|
+
* @param config - resolved switches.
|
|
217
|
+
*/
|
|
218
|
+
function assertHostAllowed(url, config) {
|
|
219
|
+
if (config.allowedHosts.length === 0) return
|
|
220
|
+
const parsed = new URL(url)
|
|
221
|
+
const host = parsed.host.toLowerCase()
|
|
222
|
+
const hostname = parsed.hostname.toLowerCase()
|
|
223
|
+
const allowed = config.allowedHosts.some((entry) => {
|
|
224
|
+
const candidate = entry.startsWith('*.') ? entry.slice(2) : entry
|
|
225
|
+
return host === candidate || hostname === candidate || hostname.endsWith(`.${candidate}`)
|
|
226
|
+
})
|
|
227
|
+
if (!allowed) {
|
|
228
|
+
throw new Error(`The plugin configuration does not allow host "${parsed.host}"; allowed hosts: ${config.allowedHosts.join(', ')}`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Join non-empty diagnostic notes.
|
|
234
|
+
* @param parts - note fragments.
|
|
235
|
+
* @returns one note string.
|
|
236
|
+
*/
|
|
237
|
+
function joinNotes(parts) {
|
|
238
|
+
return parts.filter((part) => typeof part === 'string' && part !== '').join('; ')
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Host-side facts appended to status notes.
|
|
243
|
+
* @param ctx - host plugin context.
|
|
244
|
+
* @param config - resolved switches.
|
|
245
|
+
* @returns the host diagnostic fragment.
|
|
246
|
+
*/
|
|
247
|
+
function hostFacts(ctx, config) {
|
|
248
|
+
const attachments = ctx.get('attachments')
|
|
249
|
+
const attachmentsReady = attachments !== undefined && typeof attachments.saveImage === 'function'
|
|
250
|
+
return `host-plugin=${PLUGIN_VERSION}; config=${JSON.stringify(liveConfig(config))}; attachments=${attachmentsReady ? 'available' : 'missing (screenshots unavailable)'}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Startup readiness check: verify the services the tools rely on.
|
|
255
|
+
* @param ctx - host plugin context.
|
|
256
|
+
* @param config - resolved switches.
|
|
257
|
+
* @returns the list of problems found.
|
|
258
|
+
*/
|
|
259
|
+
function selfCheck(ctx, config) {
|
|
260
|
+
const current = liveConfig(config)
|
|
261
|
+
const problems = []
|
|
262
|
+
const webServer = ctx.get('webServer')
|
|
263
|
+
if (webServer === undefined || typeof webServer.register !== 'function') problems.push('webServer service is missing route registration')
|
|
264
|
+
const connection = ctx.get('connection')
|
|
265
|
+
if (connection === undefined || typeof connection.requestRejection !== 'function') problems.push('connection service is missing requestRejection')
|
|
266
|
+
if (ctx.get('tools') === undefined) problems.push('tools service is missing')
|
|
267
|
+
const attachments = ctx.get('attachments')
|
|
268
|
+
if (attachments === undefined || typeof attachments.saveImage !== 'function') problems.push('attachments service is missing; screenshots will be unavailable')
|
|
269
|
+
const summary = `sidebar-browser-agent ${PLUGIN_VERSION}: ready (enabled=${current.enabled}, allowEval=${current.allowEval}, allowNativeInput=${current.allowNativeInput}, allowedHosts=${current.allowedHosts.length})`
|
|
270
|
+
const logger = ctx.logger
|
|
271
|
+
if (problems.length > 0) {
|
|
272
|
+
if (typeof logger?.warn === 'function') logger.warn(`${summary}; self-check: ${problems.join('; ')}`)
|
|
273
|
+
} else if (typeof logger?.info === 'function') {
|
|
274
|
+
logger.info(summary)
|
|
275
|
+
}
|
|
276
|
+
return problems
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Require one model argument to be a string.
|
|
281
|
+
* @param value - candidate value.
|
|
282
|
+
* @param name - parameter name used in the failure text.
|
|
283
|
+
* @param optional - whether absence is allowed.
|
|
284
|
+
* @returns the trimmed string or undefined.
|
|
285
|
+
*/
|
|
286
|
+
function requireString(value, name, optional = false) {
|
|
287
|
+
if (value === undefined || value === null) {
|
|
288
|
+
if (optional) return undefined
|
|
289
|
+
throw new Error(`this action requires "${name}"`)
|
|
290
|
+
}
|
|
291
|
+
if (typeof value !== 'string') throw new Error(`"${name}" must be a string`)
|
|
292
|
+
const trimmed = value.trim()
|
|
293
|
+
if (trimmed === '') {
|
|
294
|
+
if (optional) return undefined
|
|
295
|
+
throw new Error(`"${name}" must be a non-empty string`)
|
|
296
|
+
}
|
|
297
|
+
return trimmed
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @param value - candidate value.
|
|
302
|
+
* @param name - parameter name used in the failure text.
|
|
303
|
+
* @param min - inclusive lower bound.
|
|
304
|
+
* @param max - inclusive upper bound.
|
|
305
|
+
* @param fallback - value to use when the argument is absent.
|
|
306
|
+
* @returns the bounded number.
|
|
307
|
+
*/
|
|
308
|
+
function boundedNumber(value, name, min, max, fallback) {
|
|
309
|
+
if (value === undefined || value === null) return fallback
|
|
310
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`"${name}" must be a finite number`)
|
|
311
|
+
return Math.min(max, Math.max(min, Math.round(value)))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** One parked poll request waiting for commands. */
|
|
315
|
+
class PollWaiter {
|
|
316
|
+
/**
|
|
317
|
+
* @param clientId - page instance that parked the request.
|
|
318
|
+
* @param session - session the page currently shows ('' when none).
|
|
319
|
+
* @param respond - writes the command list to the parked response.
|
|
320
|
+
*/
|
|
321
|
+
constructor(clientId, session, respond) {
|
|
322
|
+
this.clientId = clientId
|
|
323
|
+
this.session = session
|
|
324
|
+
this.respond = respond
|
|
325
|
+
this.timer = undefined
|
|
326
|
+
this.answered = false
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Answer exactly once.
|
|
331
|
+
* @param commands - commands delivered to this poll.
|
|
332
|
+
*/
|
|
333
|
+
answer(commands) {
|
|
334
|
+
if (this.answered) return
|
|
335
|
+
this.answered = true
|
|
336
|
+
clearTimeout(this.timer)
|
|
337
|
+
this.respond(commands)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Cross-page command bridge for both tools.
|
|
343
|
+
*
|
|
344
|
+
* One process-wide instance owns parked polls, the command queue, and pending
|
|
345
|
+
* tool calls. Multiple pages are supported best-effort: a command is handed to
|
|
346
|
+
* a page showing the calling session when one is parked, otherwise to the
|
|
347
|
+
* first parked page.
|
|
348
|
+
*/
|
|
349
|
+
class Bridge {
|
|
350
|
+
/**
|
|
351
|
+
* @param ctx - host plugin context (carries webServer and connection).
|
|
352
|
+
*/
|
|
353
|
+
constructor(ctx) {
|
|
354
|
+
this.ctx = ctx
|
|
355
|
+
/** @type {Map<string, PollWaiter>} parked polls by page instance id */
|
|
356
|
+
this.waiters = new Map()
|
|
357
|
+
/** @type {Array<object>} commands waiting for a poll */
|
|
358
|
+
this.queue = []
|
|
359
|
+
/** @type {Map<string, { settle: (result: object) => void }>} in-flight tool calls */
|
|
360
|
+
this.pending = new Map()
|
|
361
|
+
/** @type {number} time of the most recent poll request */
|
|
362
|
+
this.lastPollAt = 0
|
|
363
|
+
/** @type {object | null} last `started` report from a page */
|
|
364
|
+
this.lastHello = null
|
|
365
|
+
/** @type {string | null} last browser-half error report */
|
|
366
|
+
this.lastError = null
|
|
367
|
+
this.disposed = false
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Whether a page is currently parked on a poll. */
|
|
371
|
+
get connected() {
|
|
372
|
+
return this.waiters.size > 0
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Register the three bridge routes.
|
|
377
|
+
* @returns a disposer removing them.
|
|
378
|
+
*/
|
|
379
|
+
registerRoutes() {
|
|
380
|
+
const disposers = [
|
|
381
|
+
this.ctx.webServer.register({
|
|
382
|
+
kind: 'exact',
|
|
383
|
+
path: ROUTES.poll,
|
|
384
|
+
handler: (req, res) => this.handlePoll(req, res),
|
|
385
|
+
}),
|
|
386
|
+
this.ctx.webServer.register({
|
|
387
|
+
kind: 'exact',
|
|
388
|
+
path: ROUTES.result,
|
|
389
|
+
handler: (req, res) => this.handleResult(req, res),
|
|
390
|
+
}),
|
|
391
|
+
this.ctx.webServer.register({
|
|
392
|
+
kind: 'exact',
|
|
393
|
+
path: ROUTES.report,
|
|
394
|
+
handler: (req, res) => this.handleReport(req, res),
|
|
395
|
+
}),
|
|
396
|
+
]
|
|
397
|
+
return () => {
|
|
398
|
+
for (const dispose of disposers) dispose()
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Admit or refuse one browser request.
|
|
404
|
+
* @param req - Node request.
|
|
405
|
+
* @returns true when the request may be served.
|
|
406
|
+
*/
|
|
407
|
+
fence(req) {
|
|
408
|
+
try {
|
|
409
|
+
return this.ctx.connection.requestRejection(req) === undefined
|
|
410
|
+
} catch {
|
|
411
|
+
return false
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `GET /poll`: hand over queued commands or park until one arrives.
|
|
417
|
+
* @param req - Node request carrying `clientId` and the shown `session`.
|
|
418
|
+
* @param res - Node response.
|
|
419
|
+
*/
|
|
420
|
+
handlePoll(req, res) {
|
|
421
|
+
if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
422
|
+
if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
423
|
+
if (this.disposed) return writeJson(res, 503, { ok: false, error: 'plugin disposed' })
|
|
424
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
425
|
+
const clientId = (url.searchParams.get('clientId') ?? '').trim()
|
|
426
|
+
if (clientId === '') return writeJson(res, 400, { ok: false, error: 'clientId is required' })
|
|
427
|
+
const session = url.searchParams.get('session') ?? ''
|
|
428
|
+
this.lastPollAt = Date.now()
|
|
429
|
+
|
|
430
|
+
const immediate = this.queue.splice(0, this.queue.length)
|
|
431
|
+
if (immediate.length > 0) return writeJson(res, 200, { commands: immediate })
|
|
432
|
+
|
|
433
|
+
const previous = this.waiters.get(clientId)
|
|
434
|
+
if (previous !== undefined) {
|
|
435
|
+
this.waiters.delete(clientId)
|
|
436
|
+
previous.answer([])
|
|
437
|
+
}
|
|
438
|
+
const waiter = new PollWaiter(clientId, session, (commands) => {
|
|
439
|
+
writeJson(res, 200, { commands })
|
|
440
|
+
})
|
|
441
|
+
waiter.timer = setTimeout(() => {
|
|
442
|
+
if (this.waiters.get(clientId) === waiter) this.waiters.delete(clientId)
|
|
443
|
+
waiter.answer([])
|
|
444
|
+
}, POLL_HOLD_MS)
|
|
445
|
+
this.waiters.set(clientId, waiter)
|
|
446
|
+
res.on('close', () => {
|
|
447
|
+
if (this.waiters.get(clientId) === waiter) this.waiters.delete(clientId)
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* `POST /result`: settle one in-flight tool call.
|
|
453
|
+
* @param req - Node request carrying `{ id, ok, value?, error? }`.
|
|
454
|
+
* @param res - Node response.
|
|
455
|
+
*/
|
|
456
|
+
async handleResult(req, res) {
|
|
457
|
+
if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
458
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
459
|
+
let body
|
|
460
|
+
try {
|
|
461
|
+
body = JSON.parse(await readBody(req, MAX_BODY_BYTES))
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid body' })
|
|
464
|
+
}
|
|
465
|
+
if (body === null || typeof body !== 'object') {
|
|
466
|
+
return writeJson(res, 400, { ok: false, error: 'invalid body' })
|
|
467
|
+
}
|
|
468
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
469
|
+
const pending = this.pending.get(id)
|
|
470
|
+
if (pending === undefined) return writeJson(res, 200, { ok: false, error: 'unknown command' })
|
|
471
|
+
this.pending.delete(id)
|
|
472
|
+
pending.settle({
|
|
473
|
+
ok: body.ok === true,
|
|
474
|
+
value: body.value,
|
|
475
|
+
error: typeof body.error === 'string' && body.error !== '' ? body.error : undefined,
|
|
476
|
+
})
|
|
477
|
+
return writeJson(res, 200, { ok: true })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* `POST /report`: record a browser-half lifecycle report for diagnostics.
|
|
482
|
+
* @param req - Node request carrying `{ status, mounted?, carrier?, href?, error? }`.
|
|
483
|
+
* @param res - Node response.
|
|
484
|
+
*/
|
|
485
|
+
async handleReport(req, res) {
|
|
486
|
+
if (!this.fence(req)) return writeJson(res, 403, { ok: false, error: 'forbidden' })
|
|
487
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
488
|
+
let body
|
|
489
|
+
try {
|
|
490
|
+
body = JSON.parse(await readBody(req, MAX_BODY_BYTES))
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return writeJson(res, 400, { ok: false, error: error instanceof Error ? error.message : 'invalid body' })
|
|
493
|
+
}
|
|
494
|
+
if (body === null || typeof body !== 'object') return writeJson(res, 400, { ok: false, error: 'invalid body' })
|
|
495
|
+
if (body.status === 'started') {
|
|
496
|
+
this.lastHello = {
|
|
497
|
+
reportedAt: Date.now(),
|
|
498
|
+
mounted: typeof body.mounted === 'string' ? body.mounted : '',
|
|
499
|
+
carrier: typeof body.carrier === 'string' ? body.carrier : '',
|
|
500
|
+
href: typeof body.href === 'string' ? body.href : '',
|
|
501
|
+
}
|
|
502
|
+
this.lastError = null
|
|
503
|
+
} else if (typeof body.error === 'string' && body.error !== '') {
|
|
504
|
+
this.lastError = body.error
|
|
505
|
+
}
|
|
506
|
+
return writeJson(res, 200, { ok: true })
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Describe why no page half answered.
|
|
511
|
+
* @returns the failure text handed to the model.
|
|
512
|
+
*/
|
|
513
|
+
noClientMessage() {
|
|
514
|
+
const detail = this.lastError !== null
|
|
515
|
+
? ` Last browser-half error: ${this.lastError}`
|
|
516
|
+
: this.lastHello === null
|
|
517
|
+
? ' The plugin\'s browser half never reported in; check Settings > Plugins for a client-side load failure, then reload the Harness page.'
|
|
518
|
+
: ` Last browser-half report: carrier=${this.lastHello.carrier || 'unknown'}, mounted=${this.lastHello.mounted === '' ? 'none' : this.lastHello.mounted}, page=${this.lastHello.href}.`
|
|
519
|
+
return `The Harness Web page is not connected to the sidebar browser bridge. Open or reload the Harness page in this profile and retry.${detail}`
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Wait until some page is parked on a poll.
|
|
524
|
+
* @param signal - caller cancellation.
|
|
525
|
+
*/
|
|
526
|
+
async ensureClient(signal) {
|
|
527
|
+
if (this.connected) return
|
|
528
|
+
if (Date.now() - this.lastPollAt > CLIENT_STALE_MS) throw new Error(this.noClientMessage())
|
|
529
|
+
const deadline = Date.now() + CLIENT_WAIT_MS
|
|
530
|
+
while (!this.connected && Date.now() < deadline) await delay(50, signal)
|
|
531
|
+
if (!this.connected) throw new Error(this.noClientMessage())
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Deliver one command to a parked poll, or queue it for the next one.
|
|
536
|
+
* @param entry - command frame including its id.
|
|
537
|
+
* @param sessionId - session the page should show.
|
|
538
|
+
*/
|
|
539
|
+
deliver(entry, sessionId) {
|
|
540
|
+
const parked = [...this.waiters.values()]
|
|
541
|
+
const preferred = parked.find((waiter) => waiter.session === sessionId) ?? parked[0]
|
|
542
|
+
if (preferred !== undefined) {
|
|
543
|
+
this.waiters.delete(preferred.clientId)
|
|
544
|
+
preferred.answer([entry])
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
this.queue.push(entry)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Send one command to the page half and await its answer.
|
|
552
|
+
* @param command - command fields without id.
|
|
553
|
+
* @param signal - caller cancellation.
|
|
554
|
+
* @returns `{ ok, value?, error? }` from the page.
|
|
555
|
+
*/
|
|
556
|
+
async dispatch(command, signal) {
|
|
557
|
+
await this.ensureClient(signal)
|
|
558
|
+
const id = randomUUID()
|
|
559
|
+
const entry = { id, ...command }
|
|
560
|
+
return await new Promise((resolve, reject) => {
|
|
561
|
+
const settle = (result) => {
|
|
562
|
+
clearTimeout(timer)
|
|
563
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
564
|
+
resolve(result)
|
|
565
|
+
}
|
|
566
|
+
const fail = (error) => {
|
|
567
|
+
clearTimeout(timer)
|
|
568
|
+
if (signal !== undefined) signal.removeEventListener('abort', onAbort)
|
|
569
|
+
reject(error)
|
|
570
|
+
}
|
|
571
|
+
const timer = setTimeout(() => {
|
|
572
|
+
if (this.pending.delete(id)) fail(new Error(`The Harness Web page did not answer within ${Math.round(RESULT_TIMEOUT_MS / 1000)}s; retry once the page is focused.`))
|
|
573
|
+
}, RESULT_TIMEOUT_MS)
|
|
574
|
+
const onAbort = () => {
|
|
575
|
+
if (this.pending.delete(id)) fail(abortError(signal?.reason))
|
|
576
|
+
}
|
|
577
|
+
if (signal !== undefined) {
|
|
578
|
+
if (signal.aborted) {
|
|
579
|
+
fail(abortError(signal.reason))
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
583
|
+
}
|
|
584
|
+
this.pending.set(id, { settle })
|
|
585
|
+
this.deliver(entry, command.sessionId)
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Drop parked polls, queued commands and in-flight calls (plugin unload). */
|
|
590
|
+
dispose() {
|
|
591
|
+
this.disposed = true
|
|
592
|
+
for (const waiter of this.waiters.values()) waiter.answer([])
|
|
593
|
+
this.waiters.clear()
|
|
594
|
+
this.queue = []
|
|
595
|
+
for (const [id, pending] of this.pending) {
|
|
596
|
+
this.pending.delete(id)
|
|
597
|
+
pending.settle({ ok: false, error: 'the sidebar browser agent plugin was unloaded' })
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---------------------------------------------------------------------------
|
|
603
|
+
// Tool schemas
|
|
604
|
+
// ---------------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
/** Parameter JSON Schema of `sidebar_browser`. */
|
|
607
|
+
const BROWSER_PARAMETERS = {
|
|
608
|
+
type: 'object',
|
|
609
|
+
properties: {
|
|
610
|
+
action: {
|
|
611
|
+
type: 'string',
|
|
612
|
+
enum: TAB_ACTIONS,
|
|
613
|
+
description: 'status | list | open | navigate | reload | focus | close.',
|
|
614
|
+
},
|
|
615
|
+
url: {
|
|
616
|
+
type: 'string',
|
|
617
|
+
description: 'HTTP(S) address for "open" (new tab) and "navigate" (load into the current tab).',
|
|
618
|
+
},
|
|
619
|
+
tabId: {
|
|
620
|
+
type: 'string',
|
|
621
|
+
description: 'Target tab id from "list" for navigate, reload, focus and close.',
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
required: ['action'],
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Output JSON Schema of `sidebar_browser`. */
|
|
628
|
+
const BROWSER_OUTPUT = {
|
|
629
|
+
type: 'object',
|
|
630
|
+
additionalProperties: false,
|
|
631
|
+
properties: {
|
|
632
|
+
action: { type: 'string', description: 'The action that ran.' },
|
|
633
|
+
sessionId: { type: 'string', description: 'Calling conversation session id.' },
|
|
634
|
+
tabId: { type: 'string', description: 'The native browser tab affected or created.' },
|
|
635
|
+
url: { type: 'string', description: 'The HTTP(S) address involved.' },
|
|
636
|
+
pageConnected: { type: 'boolean', description: 'Whether the Web page half answered (status only).' },
|
|
637
|
+
note: { type: 'string', description: 'Carrier and bridge diagnostics (status only).' },
|
|
638
|
+
tabs: {
|
|
639
|
+
type: 'array',
|
|
640
|
+
description: 'Open native sidebar browser tabs (list only).',
|
|
641
|
+
items: {
|
|
642
|
+
type: 'object',
|
|
643
|
+
additionalProperties: false,
|
|
644
|
+
properties: {
|
|
645
|
+
tabId: { type: 'string' },
|
|
646
|
+
kind: { type: 'string' },
|
|
647
|
+
url: { type: 'string' },
|
|
648
|
+
title: { type: 'string' },
|
|
649
|
+
active: { type: 'boolean' },
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
required: ['action'],
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Parameter JSON Schema of `sidebar_page`. */
|
|
658
|
+
const PAGE_PARAMETERS = {
|
|
659
|
+
type: 'object',
|
|
660
|
+
properties: {
|
|
661
|
+
action: {
|
|
662
|
+
type: 'string',
|
|
663
|
+
enum: PAGE_ACTIONS,
|
|
664
|
+
description: 'read | click | type | press | scroll | eval | wait | screenshot.',
|
|
665
|
+
},
|
|
666
|
+
tabId: {
|
|
667
|
+
type: 'string',
|
|
668
|
+
description: 'Target browser tab id; omit to use the active browser tab of the calling conversation.',
|
|
669
|
+
},
|
|
670
|
+
mode: {
|
|
671
|
+
type: 'string',
|
|
672
|
+
enum: READ_MODES,
|
|
673
|
+
description: 'read only: "text" visible text (default), "interactive" clickable elements, "html" raw markup.',
|
|
674
|
+
},
|
|
675
|
+
selector: {
|
|
676
|
+
type: 'string',
|
|
677
|
+
description: 'CSS selector for read (scope), click, type, scroll (into view) and wait.',
|
|
678
|
+
},
|
|
679
|
+
contains: {
|
|
680
|
+
type: 'string',
|
|
681
|
+
description: 'Case-insensitive visible-text match for click and wait.',
|
|
682
|
+
},
|
|
683
|
+
text: { type: 'string', description: 'type only: text to write into the field.' },
|
|
684
|
+
clear: { type: 'boolean', description: 'type only: replace the field value first (default true).' },
|
|
685
|
+
submit: { type: 'boolean', description: 'type only: press Enter after typing (default false).' },
|
|
686
|
+
key: {
|
|
687
|
+
type: 'string',
|
|
688
|
+
description: 'press only: Enter, Tab, Escape, Backspace, Delete, ArrowUp/Down/Left/Right, PageUp/Down, Home, End, Space.',
|
|
689
|
+
},
|
|
690
|
+
code: { type: 'string', description: 'eval only: JavaScript to execute in the page; returns its JSON result.' },
|
|
691
|
+
dx: { type: 'number', description: 'scroll only: horizontal pixels (positive scrolls right).' },
|
|
692
|
+
dy: { type: 'number', description: 'scroll only: vertical pixels (positive scrolls down).' },
|
|
693
|
+
x: { type: 'number', description: 'click only: viewport x coordinate when clicking by position.' },
|
|
694
|
+
y: { type: 'number', description: 'click only: viewport y coordinate when clicking by position.' },
|
|
695
|
+
maxChars: { type: 'number', description: 'read only: character cap for text/html (default 12000/30000).' },
|
|
696
|
+
maxElements: { type: 'number', description: 'read interactive only: element cap (default 200, max 500).' },
|
|
697
|
+
timeoutMs: { type: 'number', description: 'wait only: maximum wait in milliseconds (default 5000, max 15000).' },
|
|
698
|
+
},
|
|
699
|
+
required: ['action'],
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Output JSON Schema of `sidebar_page`. */
|
|
703
|
+
const PAGE_OUTPUT = {
|
|
704
|
+
type: 'object',
|
|
705
|
+
additionalProperties: false,
|
|
706
|
+
properties: {
|
|
707
|
+
action: { type: 'string', description: 'The action that ran.' },
|
|
708
|
+
sessionId: { type: 'string', description: 'Calling conversation session id.' },
|
|
709
|
+
tabId: { type: 'string', description: 'Native browser tab used.' },
|
|
710
|
+
url: { type: 'string', description: 'Page address after the action.' },
|
|
711
|
+
title: { type: 'string', description: 'Page title after the action.' },
|
|
712
|
+
mode: { type: 'string', description: 'read only: the requested mode.' },
|
|
713
|
+
text: { type: 'string', description: 'read text: visible page text.' },
|
|
714
|
+
html: { type: 'string', description: 'read html: page markup.' },
|
|
715
|
+
totalChars: { type: 'integer', description: 'read only: characters before the cap.' },
|
|
716
|
+
truncated: { type: 'boolean', description: 'read/eval only: whether the result hit its cap.' },
|
|
717
|
+
viewport: {
|
|
718
|
+
type: 'object',
|
|
719
|
+
additionalProperties: false,
|
|
720
|
+
properties: { width: { type: 'integer' }, height: { type: 'integer' } },
|
|
721
|
+
},
|
|
722
|
+
scroll: {
|
|
723
|
+
type: 'object',
|
|
724
|
+
additionalProperties: false,
|
|
725
|
+
properties: { x: { type: 'integer' }, y: { type: 'integer' }, height: { type: 'integer' } },
|
|
726
|
+
},
|
|
727
|
+
elements: {
|
|
728
|
+
type: 'array',
|
|
729
|
+
description: 'read interactive: visible interactive elements.',
|
|
730
|
+
items: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
additionalProperties: false,
|
|
733
|
+
properties: {
|
|
734
|
+
index: { type: 'integer' },
|
|
735
|
+
tag: { type: 'string' },
|
|
736
|
+
type: { type: 'string' },
|
|
737
|
+
label: { type: 'string' },
|
|
738
|
+
href: { type: 'string' },
|
|
739
|
+
selector: { type: 'string' },
|
|
740
|
+
x: { type: 'integer' },
|
|
741
|
+
y: { type: 'integer' },
|
|
742
|
+
disabled: { type: 'boolean' },
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
target: {
|
|
747
|
+
type: 'object',
|
|
748
|
+
additionalProperties: false,
|
|
749
|
+
properties: {
|
|
750
|
+
tag: { type: 'string' },
|
|
751
|
+
label: { type: 'string' },
|
|
752
|
+
selector: { type: 'string' },
|
|
753
|
+
x: { type: 'integer' },
|
|
754
|
+
y: { type: 'integer' },
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
typed: { type: 'integer', description: 'type only: characters written.' },
|
|
758
|
+
submitted: { type: 'boolean', description: 'type only: whether Enter was pressed.' },
|
|
759
|
+
key: { type: 'string', description: 'press only: the key code dispatched.' },
|
|
760
|
+
scrollX: { type: 'integer' },
|
|
761
|
+
scrollY: { type: 'integer' },
|
|
762
|
+
scrollHeight: { type: 'integer' },
|
|
763
|
+
viewportHeight: { type: 'integer' },
|
|
764
|
+
matched: { type: 'boolean', description: 'wait only: whether the condition became true.' },
|
|
765
|
+
waitedMs: { type: 'integer', description: 'wait only: elapsed milliseconds.' },
|
|
766
|
+
selector: { type: 'string', description: 'wait only: the selector waited for.' },
|
|
767
|
+
contains: { type: 'string', description: 'wait only: the text waited for.' },
|
|
768
|
+
result: { type: 'string', description: 'eval only: JSON text of the script result.' },
|
|
769
|
+
note: { type: 'string', description: 'Extra context; present when the operation ran while another conversation was on screen.' },
|
|
770
|
+
width: { type: 'integer', description: 'screenshot only: image width.' },
|
|
771
|
+
height: { type: 'integer', description: 'screenshot only: image height.' },
|
|
772
|
+
image: {
|
|
773
|
+
type: 'object',
|
|
774
|
+
description: 'screenshot only: durable image attachment reference.',
|
|
775
|
+
additionalProperties: false,
|
|
776
|
+
properties: {
|
|
777
|
+
attachmentId: { type: 'string' },
|
|
778
|
+
mediaType: { type: 'string' },
|
|
779
|
+
bytes: { type: 'integer' },
|
|
780
|
+
width: { type: 'integer' },
|
|
781
|
+
height: { type: 'integer' },
|
|
782
|
+
name: { type: 'string' },
|
|
783
|
+
},
|
|
784
|
+
required: ['attachmentId', 'mediaType', 'bytes', 'width', 'height'],
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
required: ['action'],
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Tool descriptions and rendering
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/** Model-facing description of `sidebar_browser`. */
|
|
795
|
+
const BROWSER_DESCRIPTION =
|
|
796
|
+
'Manage the native browser tabs of the DSH right sidebar for the calling conversation. '
|
|
797
|
+
+ '"open" shows an HTTP(S) page in a new sidebar browser tab; "list" returns open tabs with tab ids, URLs and titles; '
|
|
798
|
+
+ '"navigate", "reload", "focus" and "close" act on one tab; "status" reports the page carrier and bridge health. '
|
|
799
|
+
+ 'The Harness Web page must be open. "navigate" and "reload" drive the native guest and also work while another conversation is on screen; '
|
|
800
|
+
+ '"open", "focus" and "close" change the visible sidebar layout and need the calling conversation to be the one on screen. '
|
|
801
|
+
+ 'Use sidebar_page to read or interact with the loaded page, and web_fetch for page text without a tab.'
|
|
802
|
+
|
|
803
|
+
/** Model-facing description of `sidebar_page`. */
|
|
804
|
+
const PAGE_DESCRIPTION =
|
|
805
|
+
'Read and operate the page loaded in a native DSH sidebar browser tab, on the real Chromium guest: '
|
|
806
|
+
+ '"read" returns visible text, the list of interactive elements, or markup; '
|
|
807
|
+
+ '"click" clicks an element by CSS selector, by visible text, or by viewport coordinates using native input; '
|
|
808
|
+
+ '"type" writes into a field and optionally submits; "press" sends a native key; "scroll" moves the page or an element into view; '
|
|
809
|
+
+ '"wait" polls for a selector or text; "eval" runs JavaScript in the page and returns its JSON result; '
|
|
810
|
+
+ '"screenshot" captures the visible page and returns the image. '
|
|
811
|
+
+ 'Works in the DSH Desktop app (native Electron browser). In a Web build the sidebar browser is a sandboxed cross-origin iframe, so page actions fail and only tab management plus web_fetch work. '
|
|
812
|
+
+ 'A browser tab must be open in the calling conversation (sidebar_browser action "open" or "list"); page actions also work while another conversation is on screen, on the calling conversation\'s hidden sidebar tab. '
|
|
813
|
+
+ 'Call "read" with mode "interactive" before clicking when you do not know the page structure.'
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Human-readable projection of one `sidebar_browser` value.
|
|
817
|
+
* @param _args - model arguments (unused).
|
|
818
|
+
* @param value - canonical tool value.
|
|
819
|
+
* @returns model-facing content blocks.
|
|
820
|
+
*/
|
|
821
|
+
function renderBrowser(_args, value) {
|
|
822
|
+
const result = value ?? {}
|
|
823
|
+
if (result.action === 'status') {
|
|
824
|
+
const head = result.pageConnected === true
|
|
825
|
+
? 'Sidebar browser bridge: connected.'
|
|
826
|
+
: 'Sidebar browser bridge: not connected.'
|
|
827
|
+
return [{ type: 'text', text: result.note === undefined ? head : `${head} ${result.note}` }]
|
|
828
|
+
}
|
|
829
|
+
if (result.action === 'list') {
|
|
830
|
+
const tabs = Array.isArray(result.tabs) ? result.tabs : []
|
|
831
|
+
if (tabs.length === 0) return [{ type: 'text', text: 'No native sidebar browser tabs are open for this conversation.' }]
|
|
832
|
+
const lines = tabs.map((tab) => {
|
|
833
|
+
const bits = [`- ${tab.tabId}${tab.active === true ? ' (active)' : ''}`]
|
|
834
|
+
if (tab.title !== undefined) bits.push(`"${tab.title}"`)
|
|
835
|
+
if (tab.url !== undefined) bits.push(tab.url)
|
|
836
|
+
return bits.join(' ')
|
|
837
|
+
})
|
|
838
|
+
return [{ type: 'text', text: `Native sidebar browser tabs (${tabs.length}):\n${lines.join('\n')}` }]
|
|
839
|
+
}
|
|
840
|
+
const parts = [`sidebar_browser ${String(result.action)} completed.`]
|
|
841
|
+
if (result.tabId !== undefined) parts.push(`tab: ${result.tabId}`)
|
|
842
|
+
if (result.url !== undefined) parts.push(`url: ${result.url}`)
|
|
843
|
+
if (result.note !== undefined) parts.push(String(result.note))
|
|
844
|
+
return [{ type: 'text', text: parts.join(' ') }]
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Format the interactive-element list of a read result.
|
|
849
|
+
* @param elements - element summaries.
|
|
850
|
+
* @returns one text block.
|
|
851
|
+
*/
|
|
852
|
+
function renderElements(elements) {
|
|
853
|
+
const lines = elements.map((element) => {
|
|
854
|
+
const bits = [`[${element.index}] <${element.tag}${element.type === undefined ? '' : ` type=${element.type}`}>`]
|
|
855
|
+
if (element.label !== undefined) bits.push(`"${element.label}"`)
|
|
856
|
+
if (element.selector !== undefined) bits.push(`selector=${element.selector}`)
|
|
857
|
+
bits.push(`@(${element.x},${element.y})`)
|
|
858
|
+
if (element.href !== undefined) bits.push(element.href)
|
|
859
|
+
if (element.disabled === true) bits.push('[disabled]')
|
|
860
|
+
return bits.join(' ')
|
|
861
|
+
})
|
|
862
|
+
return [{ type: 'text', text: `Interactive elements (${elements.length}):\n${lines.join('\n')}` }]
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Human-readable projection of one `sidebar_page` value, without its note.
|
|
867
|
+
* @param result - canonical tool value.
|
|
868
|
+
* @returns model-facing content blocks.
|
|
869
|
+
*/
|
|
870
|
+
function renderPageBody(result) {
|
|
871
|
+
const where = [result.url === undefined ? '' : String(result.url), result.title === undefined ? '' : `"${String(result.title)}"`]
|
|
872
|
+
.filter((part) => part !== '')
|
|
873
|
+
.join(' ')
|
|
874
|
+
const header = where === '' ? '' : `${where}\n`
|
|
875
|
+
|
|
876
|
+
if (result.action === 'read') {
|
|
877
|
+
if (result.mode === 'interactive') {
|
|
878
|
+
const elements = Array.isArray(result.elements) ? result.elements : []
|
|
879
|
+
const info = `Page ${header}viewport ${result.viewport?.width ?? '?'}x${result.viewport?.height ?? '?'}, scrollY ${result.scroll?.y ?? '?'}/${result.scroll?.height ?? '?'}.\n`
|
|
880
|
+
return [{ type: 'text', text: info + renderElements(elements)[0].text }]
|
|
881
|
+
}
|
|
882
|
+
if (result.mode === 'html') {
|
|
883
|
+
const suffix = result.truncated === true ? `\n… truncated (${result.totalChars ?? '?'} chars total)` : ''
|
|
884
|
+
return [{ type: 'text', text: `${header}${String(result.html ?? '')}${suffix}` }]
|
|
885
|
+
}
|
|
886
|
+
const suffix = result.truncated === true ? `\n… truncated (${result.totalChars ?? '?'} chars total)` : ''
|
|
887
|
+
return [{ type: 'text', text: `${header}${String(result.text ?? '')}${suffix}` }]
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (result.action === 'screenshot') {
|
|
891
|
+
const image = result.image ?? {}
|
|
892
|
+
const attachment = {
|
|
893
|
+
attachmentId: String(image.attachmentId ?? ''),
|
|
894
|
+
mediaType: String(image.mediaType ?? 'image/png'),
|
|
895
|
+
bytes: Number(image.bytes ?? 0),
|
|
896
|
+
width: Number(image.width ?? result.width ?? 0),
|
|
897
|
+
height: Number(image.height ?? result.height ?? 0),
|
|
898
|
+
}
|
|
899
|
+
if (typeof image.name === 'string' && image.name !== '') attachment.name = image.name
|
|
900
|
+
return [
|
|
901
|
+
{ type: 'text', text: `Sidebar browser screenshot of ${header || 'the page'} (${attachment.width}x${attachment.height} px).` },
|
|
902
|
+
{ type: 'image', attachment },
|
|
903
|
+
]
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (result.action === 'click') {
|
|
907
|
+
const target = result.target ?? {}
|
|
908
|
+
const label = target.label === undefined ? '' : ` "${target.label}"`
|
|
909
|
+
return [{ type: 'text', text: `Clicked <${target.tag ?? 'element'}>${label} at (${target.x ?? '?'},${target.y ?? '?'}) in ${header || 'the page'}` }]
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (result.action === 'type') {
|
|
913
|
+
const target = result.target ?? {}
|
|
914
|
+
return [{
|
|
915
|
+
type: 'text',
|
|
916
|
+
text: `Typed ${result.typed ?? 0} characters into <${target.tag ?? 'field'}>${result.submitted === true ? ' and pressed Enter' : ''} in ${header || 'the page'}`,
|
|
917
|
+
}]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (result.action === 'press') {
|
|
921
|
+
return [{ type: 'text', text: `Pressed ${result.key ?? 'key'} in ${header || 'the page'}` }]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (result.action === 'scroll') {
|
|
925
|
+
return [{
|
|
926
|
+
type: 'text',
|
|
927
|
+
text: `Scrolled ${header || 'the page'} to y=${result.scrollY ?? '?'} of ${result.scrollHeight ?? '?'} (viewport ${result.viewportHeight ?? '?'} px).`,
|
|
928
|
+
}]
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (result.action === 'wait') {
|
|
932
|
+
return [{
|
|
933
|
+
type: 'text',
|
|
934
|
+
text: result.matched === true
|
|
935
|
+
? `Condition met after ${result.waitedMs ?? 0} ms in ${header || 'the page'}.`
|
|
936
|
+
: `Condition not met after ${result.waitedMs ?? 0} ms in ${header || 'the page'}.`,
|
|
937
|
+
}]
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (result.action === 'eval') {
|
|
941
|
+
const suffix = result.truncated === true ? '\n… result truncated' : ''
|
|
942
|
+
return [{ type: 'text', text: `JavaScript result for ${header || 'the page'}:\n${String(result.result ?? 'undefined')}${suffix}` }]
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return [{ type: 'text', text: `sidebar_page ${String(result.action)} completed.` }]
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Project one `sidebar_page` value and append its optional note.
|
|
950
|
+
* @param _args - model arguments (unused).
|
|
951
|
+
* @param value - canonical tool value.
|
|
952
|
+
* @returns model-facing content blocks.
|
|
953
|
+
*/
|
|
954
|
+
function renderPage(_args, value) {
|
|
955
|
+
const result = value ?? {}
|
|
956
|
+
const blocks = renderPageBody(result)
|
|
957
|
+
if (typeof result.note === 'string' && result.note !== '') blocks.push({ type: 'text', text: result.note })
|
|
958
|
+
return blocks
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// ---------------------------------------------------------------------------
|
|
962
|
+
// Tool execution helpers
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Extract the calling session id or throw the canonical error.
|
|
967
|
+
* @param exec - tool execution context.
|
|
968
|
+
* @returns the session id.
|
|
969
|
+
*/
|
|
970
|
+
function sessionIdOf(exec) {
|
|
971
|
+
const raw = exec.agent?.session?.id
|
|
972
|
+
if (typeof raw !== 'string' || raw === '') throw new Error('this tool requires an initiating agent session')
|
|
973
|
+
return raw
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Throw the page half's failure as a tool error.
|
|
978
|
+
* @param result - bridge result.
|
|
979
|
+
* @param fallback - message used when the page half gave no reason.
|
|
980
|
+
* @returns never.
|
|
981
|
+
*/
|
|
982
|
+
function throwPageFailure(result, fallback) {
|
|
983
|
+
throw new Error(result.error ?? fallback)
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Register the `sidebar_browser` tool.
|
|
988
|
+
* @param ctx - host plugin context.
|
|
989
|
+
* @param bridge - running bridge.
|
|
990
|
+
* @returns a disposer unregistering the tool.
|
|
991
|
+
*/
|
|
992
|
+
function registerBrowserTool(ctx, bridge, config) {
|
|
993
|
+
return ctx.tools.register({
|
|
994
|
+
name: 'sidebar_browser',
|
|
995
|
+
description: BROWSER_DESCRIPTION,
|
|
996
|
+
parameters: BROWSER_PARAMETERS,
|
|
997
|
+
timeoutMs: 60_000,
|
|
998
|
+
output: { schema: BROWSER_OUTPUT, render: renderBrowser },
|
|
999
|
+
async execute(rawArgs, exec) {
|
|
1000
|
+
exec.signal?.throwIfAborted?.()
|
|
1001
|
+
if (rawArgs === null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) throw new Error('sidebar_browser requires an object of arguments')
|
|
1002
|
+
const action = rawArgs.action
|
|
1003
|
+
if (typeof action !== 'string' || !TAB_ACTIONS.includes(action)) throw new Error(`sidebar_browser action must be one of: ${TAB_ACTIONS.join(', ')}`)
|
|
1004
|
+
const sessionId = typeof exec.agent?.session?.id === 'string' ? exec.agent.session.id : ''
|
|
1005
|
+
|
|
1006
|
+
if (action === 'status') {
|
|
1007
|
+
const command = { action, sessionId }
|
|
1008
|
+
if (!bridge.connected && Date.now() - bridge.lastPollAt > CLIENT_STALE_MS) {
|
|
1009
|
+
return {
|
|
1010
|
+
action: 'status',
|
|
1011
|
+
...(sessionId === '' ? {} : { sessionId }),
|
|
1012
|
+
pageConnected: false,
|
|
1013
|
+
note: joinNotes([bridge.noClientMessage(), hostFacts(ctx, config)]),
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
await bridge.ensureClient(exec.signal)
|
|
1017
|
+
const result = await bridge.dispatch(command, exec.signal)
|
|
1018
|
+
if (result.ok !== true) throwPageFailure(result, 'the page half did not answer')
|
|
1019
|
+
const value = result.value ?? {}
|
|
1020
|
+
const clientMatch = typeof value.note === 'string' ? /"plugin":"([^"]+)"/.exec(value.note) : null
|
|
1021
|
+
const clientVersion = clientMatch === null ? undefined : clientMatch[1]
|
|
1022
|
+
const versionNotice = clientVersion !== undefined && clientVersion !== PLUGIN_VERSION
|
|
1023
|
+
? `warning: the page half is v${clientVersion} but the running Host half is v${PLUGIN_VERSION}; restart the Harness to load the installed Host code.`
|
|
1024
|
+
: ''
|
|
1025
|
+
return { ...value, note: joinNotes([typeof value.note === 'string' ? value.note : '', hostFacts(ctx, config), versionNotice]) }
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (sessionId === '') throw new Error('sidebar_browser requires an initiating agent session')
|
|
1029
|
+
const command = { action, sessionId }
|
|
1030
|
+
if (URL_ACTIONS.has(action)) {
|
|
1031
|
+
command.url = normalizeHttpUrl(rawArgs.url, action)
|
|
1032
|
+
assertHostAllowed(command.url, liveConfig(config))
|
|
1033
|
+
}
|
|
1034
|
+
if (TAB_ID_ACTIONS.has(action)) {
|
|
1035
|
+
const tabId = requireString(rawArgs.tabId, 'tabId')
|
|
1036
|
+
if (tabId !== undefined) command.tabId = tabId
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
await bridge.ensureClient(exec.signal)
|
|
1040
|
+
const result = await bridge.dispatch(command, exec.signal)
|
|
1041
|
+
if (result.ok !== true) throwPageFailure(result, 'the sidebar browser did not complete the command')
|
|
1042
|
+
return result.value
|
|
1043
|
+
},
|
|
1044
|
+
}, { when: () => liveConfig(config).enabled })
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Register the `sidebar_page` tool.
|
|
1049
|
+
* @param ctx - host plugin context.
|
|
1050
|
+
* @param bridge - running bridge.
|
|
1051
|
+
* @returns a disposer unregistering the tool.
|
|
1052
|
+
*/
|
|
1053
|
+
function registerPageTool(ctx, bridge, config) {
|
|
1054
|
+
return ctx.tools.register({
|
|
1055
|
+
name: 'sidebar_page',
|
|
1056
|
+
description: PAGE_DESCRIPTION,
|
|
1057
|
+
parameters: PAGE_PARAMETERS,
|
|
1058
|
+
timeoutMs: 90_000,
|
|
1059
|
+
output: { schema: PAGE_OUTPUT, render: renderPage },
|
|
1060
|
+
async execute(rawArgs, exec) {
|
|
1061
|
+
exec.signal?.throwIfAborted?.()
|
|
1062
|
+
if (rawArgs === null || typeof rawArgs !== 'object' || Array.isArray(rawArgs)) throw new Error('sidebar_page requires an object of arguments')
|
|
1063
|
+
const action = rawArgs.action
|
|
1064
|
+
if (typeof action !== 'string' || !PAGE_ACTIONS.includes(action)) throw new Error(`sidebar_page action must be one of: ${PAGE_ACTIONS.join(', ')}`)
|
|
1065
|
+
const current = liveConfig(config)
|
|
1066
|
+
if (action === 'eval' && current.allowEval !== true) throw new Error('sidebar_page action "eval" is disabled by the plugin configuration')
|
|
1067
|
+
if ((action === 'click' || action === 'type' || action === 'press') && current.allowNativeInput !== true) {
|
|
1068
|
+
throw new Error('sidebar_page click/type/press actions are disabled by the plugin configuration')
|
|
1069
|
+
}
|
|
1070
|
+
const sessionId = sessionIdOf(exec)
|
|
1071
|
+
const tabId = requireString(rawArgs.tabId, 'tabId', true)
|
|
1072
|
+
const selector = requireString(rawArgs.selector, 'selector', true)
|
|
1073
|
+
const contains = requireString(rawArgs.contains, 'contains', true)
|
|
1074
|
+
const command = {
|
|
1075
|
+
action,
|
|
1076
|
+
sessionId,
|
|
1077
|
+
...(tabId === undefined ? {} : { tabId }),
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (action === 'read') {
|
|
1081
|
+
const mode = rawArgs.mode === undefined ? 'text' : rawArgs.mode
|
|
1082
|
+
if (typeof mode !== 'string' || !READ_MODES.includes(mode)) throw new Error(`read mode must be one of: ${READ_MODES.join(', ')}`)
|
|
1083
|
+
command.mode = mode
|
|
1084
|
+
if (selector !== undefined) command.selector = selector
|
|
1085
|
+
if (mode === 'text' || mode === 'html') {
|
|
1086
|
+
command.maxChars = boundedNumber(rawArgs.maxChars, 'maxChars', 100, mode === 'html' ? 120_000 : 60_000, mode === 'html' ? 30_000 : 12_000)
|
|
1087
|
+
} else {
|
|
1088
|
+
command.maxElements = boundedNumber(rawArgs.maxElements, 'maxElements', 1, 500, 200)
|
|
1089
|
+
}
|
|
1090
|
+
} else if (action === 'click') {
|
|
1091
|
+
const hasPoint = typeof rawArgs.x === 'number' && typeof rawArgs.y === 'number'
|
|
1092
|
+
if (selector === undefined && contains === undefined && !hasPoint) throw new Error('click needs "selector", "contains", or numeric "x" and "y"')
|
|
1093
|
+
if (selector !== undefined) command.selector = selector
|
|
1094
|
+
if (contains !== undefined) command.contains = contains
|
|
1095
|
+
if (hasPoint) {
|
|
1096
|
+
command.x = boundedNumber(rawArgs.x, 'x', 0, 10_000, 0)
|
|
1097
|
+
command.y = boundedNumber(rawArgs.y, 'y', 0, 10_000, 0)
|
|
1098
|
+
}
|
|
1099
|
+
} else if (action === 'type') {
|
|
1100
|
+
if (rawArgs.text === undefined || typeof rawArgs.text !== 'string') throw new Error('type requires "text"')
|
|
1101
|
+
command.text = rawArgs.text
|
|
1102
|
+
if (selector !== undefined) command.selector = selector
|
|
1103
|
+
command.clear = rawArgs.clear !== false
|
|
1104
|
+
command.submit = rawArgs.submit === true
|
|
1105
|
+
} else if (action === 'press') {
|
|
1106
|
+
const key = requireString(rawArgs.key, 'key')
|
|
1107
|
+
if (key !== undefined) command.key = key
|
|
1108
|
+
} else if (action === 'scroll') {
|
|
1109
|
+
if (selector === undefined && typeof rawArgs.dy !== 'number' && typeof rawArgs.dx !== 'number') {
|
|
1110
|
+
throw new Error('scroll needs "selector", "dy", or "dx"')
|
|
1111
|
+
}
|
|
1112
|
+
if (selector !== undefined) command.selector = selector
|
|
1113
|
+
if (typeof rawArgs.dy === 'number') command.dy = boundedNumber(rawArgs.dy, 'dy', -100_000, 100_000, 0)
|
|
1114
|
+
if (typeof rawArgs.dx === 'number') command.dx = boundedNumber(rawArgs.dx, 'dx', -100_000, 100_000, 0)
|
|
1115
|
+
} else if (action === 'eval') {
|
|
1116
|
+
const code = requireString(rawArgs.code, 'code')
|
|
1117
|
+
if (code === undefined || code.length > 200_000) throw new Error('eval "code" must be 1..200000 characters')
|
|
1118
|
+
command.code = code
|
|
1119
|
+
} else if (action === 'wait') {
|
|
1120
|
+
if (selector === undefined && contains === undefined) throw new Error('wait needs "selector" or "contains"')
|
|
1121
|
+
if (selector !== undefined) command.selector = selector
|
|
1122
|
+
if (contains !== undefined) command.contains = contains
|
|
1123
|
+
command.timeoutMs = boundedNumber(rawArgs.timeoutMs, 'timeoutMs', 100, 15_000, 5_000)
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
await bridge.ensureClient(exec.signal)
|
|
1127
|
+
const result = await bridge.dispatch(command, exec.signal)
|
|
1128
|
+
if (result.ok !== true) throwPageFailure(result, 'the sidebar page action failed')
|
|
1129
|
+
|
|
1130
|
+
if (action !== 'screenshot') return result.value
|
|
1131
|
+
|
|
1132
|
+
const value = result.value ?? {}
|
|
1133
|
+
const base64 = typeof value.base64 === 'string' ? value.base64 : ''
|
|
1134
|
+
if (base64 === '') throw new Error('the page half returned no screenshot payload')
|
|
1135
|
+
if (base64.length > MAX_SCREENSHOT_BASE64) throw new Error('the screenshot is too large to store; zoom or scroll and retry')
|
|
1136
|
+
const attachments = ctx.get('attachments')
|
|
1137
|
+
if (attachments === undefined) throw new Error('screenshots require the attachment service, which is not mounted in this profile')
|
|
1138
|
+
let ref
|
|
1139
|
+
try {
|
|
1140
|
+
ref = await attachments.saveImage({
|
|
1141
|
+
data: Buffer.from(base64, 'base64'),
|
|
1142
|
+
mediaType: 'image/png',
|
|
1143
|
+
name: `sidebar-browser-${value.tabId ?? 'tab'}.png`,
|
|
1144
|
+
})
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
throw new Error(`could not store the sidebar screenshot: ${error instanceof Error ? error.message : String(error)}`)
|
|
1147
|
+
}
|
|
1148
|
+
return {
|
|
1149
|
+
action: 'screenshot',
|
|
1150
|
+
sessionId: value.sessionId ?? sessionId,
|
|
1151
|
+
...(value.tabId === undefined ? {} : { tabId: value.tabId }),
|
|
1152
|
+
...(value.url === undefined ? {} : { url: value.url }),
|
|
1153
|
+
...(value.title === undefined ? {} : { title: value.title }),
|
|
1154
|
+
width: ref.width,
|
|
1155
|
+
height: ref.height,
|
|
1156
|
+
image: {
|
|
1157
|
+
attachmentId: ref.attachmentId,
|
|
1158
|
+
mediaType: ref.mediaType,
|
|
1159
|
+
bytes: ref.bytes,
|
|
1160
|
+
width: ref.width,
|
|
1161
|
+
height: ref.height,
|
|
1162
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
1163
|
+
},
|
|
1164
|
+
}
|
|
1165
|
+
},
|
|
1166
|
+
}, { when: () => liveConfig(config).enabled })
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Plugin body: mount the bridge routes, the configured tools, and teardown.
|
|
1171
|
+
* @param ctx - host plugin context.
|
|
1172
|
+
* @param config - validated plugin configuration.
|
|
1173
|
+
*/
|
|
1174
|
+
export function apply(ctx, config) {
|
|
1175
|
+
const bridge = new Bridge(ctx)
|
|
1176
|
+
ctx.effect(() => bridge.registerRoutes(), 'sidebar-browser-agent: bridge routes')
|
|
1177
|
+
ctx.effect(() => registerBrowserTool(ctx, bridge, config), 'sidebar-browser-agent: browser tool')
|
|
1178
|
+
ctx.effect(() => registerPageTool(ctx, bridge, config), 'sidebar-browser-agent: page tool')
|
|
1179
|
+
ctx.effect(() => () => bridge.dispose(), 'sidebar-browser-agent: bridge lifetime')
|
|
1180
|
+
selfCheck(ctx, config)
|
|
1181
|
+
}
|