dsh-inner-browser-use 0.11.44 → 0.13.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/README.md +108 -75
- package/README.zh.md +88 -45
- package/client.js +473 -3276
- package/docs/ARCHITECTURE.md +183 -151
- package/docs/BENCH.md +124 -0
- package/docs/COMPATIBILITY.md +72 -102
- package/docs/ROADMAP.md +108 -0
- package/docs/VERIFY.md +80 -417
- package/index.js +227 -1117
- package/locale/en.json +11 -22
- package/locale/zh.json +11 -22
- package/package.json +9 -5
- package/scripts/doctor.mjs +89 -0
- package/sidecar.js +1323 -0
- package/docs/upstream/cookie-persistence.md +0 -52
package/index.js
CHANGED
|
@@ -1,127 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-inner-browser-use — host half. // ui-copy-ignore
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The browser this plugin drives is its own Chrome: a real process, started // ui-copy-ignore
|
|
5
|
+
* with a profile directory outside the Host, so logins outlive a plugin // ui-copy-ignore
|
|
6
|
+
* reload and a Host restart. The Host owns it and drives it over the DevTools // ui-copy-ignore
|
|
7
|
+
* protocol, so a tool works whether or not a chat is on screen. // ui-copy-ignore
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* The Client never touches that Chrome. It opens one server-sent event stream
|
|
10
|
+
* of JPEG frames and posts input back:
|
|
10
11
|
*
|
|
11
|
-
* - the
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
12
|
+
* - `GET /sidecar/frames` — the mirror, plus pointer messages; // ui-copy-ignore
|
|
13
|
+
* - `GET /sidecar/state` — what the browser is; // ui-copy-ignore
|
|
14
|
+
* - `POST /sidecar/open` — start or reuse it, and ask for the tab; // ui-copy-ignore
|
|
15
|
+
* - `POST /sidecar/input` — mouse, wheel, text and keys; // ui-copy-ignore
|
|
16
|
+
* - `POST /sidecar/nav` — address, back, forward, reload; // ui-copy-ignore
|
|
17
|
+
* - `POST /sidecar/stop` — close the browser and keep the profile. // ui-copy-ignore
|
|
16
18
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* product document is `dsh-app://app`, whose protocol handler forwards any path
|
|
20
|
-
* it does not serve itself to the Host with the Host cookie attached — so a // ui-copy-ignore
|
|
21
|
-
* relative `fetch` from the page reaches these routes without CORS and without
|
|
22
|
-
* the Client learning a port or a token. // ui-copy-ignore
|
|
23
|
-
*
|
|
24
|
-
* One transport, two faces, no port, no secret: that is the whole design. // ui-copy-ignore
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { createWriteStream, readFileSync, statSync } from 'node:fs'
|
|
28
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
29
|
-
import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
30
|
-
import { homedir } from 'node:os'
|
|
31
|
-
import { basename, dirname, join, resolve } from 'node:path'
|
|
32
|
-
import { fileURLToPath } from 'node:url'
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Everything this plugin writes lands under this one folder of the chat's working
|
|
36
|
-
* directory, so a project's root gains nothing but a single hidden name.
|
|
19
|
+
* Every route is same-origin through the product document, so the Client
|
|
20
|
+
* learns no port and no secret. // ui-copy-ignore
|
|
37
21
|
*/
|
|
38
|
-
const WORK_DIR = '.dsh-inner-browser-use'
|
|
39
22
|
|
|
40
|
-
|
|
41
|
-
const SHOT_DIR = join(WORK_DIR, 'screenshots')
|
|
42
|
-
|
|
43
|
-
/** Files the page offers. */
|
|
44
|
-
const DOWNLOAD_DIR = join(WORK_DIR, 'downloads')
|
|
45
|
-
|
|
46
|
-
/** Makes that folder keep itself out of the project's own version control. */
|
|
47
|
-
const KEEP = '# written by the chat while it reads pages\n*\n'
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Sign-in cookies wait here between app runs: one file per workspace key, in the
|
|
51
|
-
* user's profile rather than the project, readable by that user only.
|
|
52
|
-
* @returns {string} folder that holds them.
|
|
53
|
-
*/
|
|
54
|
-
function cookieDir() {
|
|
55
|
-
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'inner-browser-use', 'cookies')
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** What the guest may keep: more than this is a page resetting itself, not a sign-in. */
|
|
59
|
-
const MAX_COOKIES = 600
|
|
60
|
-
const MAX_COOKIE_BYTES = 512 * 1024
|
|
61
|
-
|
|
62
|
-
/** Largest file the plugin will push into a page. */
|
|
63
|
-
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024
|
|
64
|
-
|
|
65
|
-
/** Types the guest needs for the common cases; the page rarely checks. */
|
|
66
|
-
const TYPES = {
|
|
67
|
-
'.png': 'image/png',
|
|
68
|
-
'.jpg': 'image/jpeg',
|
|
69
|
-
'.jpeg': 'image/jpeg',
|
|
70
|
-
'.webp': 'image/webp',
|
|
71
|
-
'.gif': 'image/gif',
|
|
72
|
-
'.pdf': 'application/pdf',
|
|
73
|
-
'.txt': 'text/plain',
|
|
74
|
-
'.json': 'application/json',
|
|
75
|
-
'.csv': 'text/csv',
|
|
76
|
-
'.zip': 'application/zip',
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** @param {string} file - absolute path. @returns {string} media type for the guest. */
|
|
80
|
-
function mediaType(file) {
|
|
81
|
-
const dot = file.lastIndexOf('.')
|
|
82
|
-
return TYPES[file.slice(dot).toLowerCase()] ?? 'application/octet-stream'
|
|
83
|
-
}
|
|
23
|
+
import { Sidecar } from './sidecar.js?v=1'
|
|
84
24
|
|
|
85
25
|
/** Route prefix owned by this plugin. Never under `/api`, which is the Host's own channel. */ // ui-copy-ignore
|
|
86
26
|
export const ROUTE = '/dsh-inner-browser-use'
|
|
87
27
|
|
|
88
|
-
/** Largest
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
/** How long one page operation may take before the Host gives up on it. */ // ui-copy-ignore
|
|
92
|
-
const DEFAULT_TIMEOUT_MS = 25_000
|
|
93
|
-
|
|
94
|
-
/** Navigation waits on the page itself, which is slower than reading it. */
|
|
95
|
-
const NAVIGATE_TIMEOUT_MS = 60_000
|
|
96
|
-
|
|
97
|
-
/** A file crosses the page, the bridge and the disk inside this budget. */
|
|
98
|
-
const DOWNLOAD_TIMEOUT_MS = 120_000
|
|
99
|
-
|
|
100
|
-
/** How long a tool waits for the tab it asked for. */
|
|
101
|
-
const OPEN_WAIT_MS = 20_000
|
|
102
|
-
|
|
103
|
-
/** Shown when a tool runs without the tab that owns the guest. */
|
|
104
|
-
const EVENT_KEEP = 300
|
|
105
|
-
/** Said when a tool run does not name the chat it belongs to. */
|
|
106
|
-
const NO_CHAT = 'this call has no chat identity, so no page is touched'
|
|
107
|
-
|
|
108
|
-
/** @param {any} exec - tool run context. @returns {string} the chat id, never empty. */
|
|
109
|
-
function chatOf(exec) {
|
|
110
|
-
const chat = String(exec?.agent?.id ?? '')
|
|
111
|
-
if (chat === '') throw new Error(NO_CHAT)
|
|
112
|
-
return chat
|
|
113
|
-
}
|
|
114
|
-
const ASK_GAP_MS = 3_000
|
|
115
|
-
const CLIENT_TTL_MS = 60_000
|
|
116
|
-
const NO_TAB = 'The built-in browser is not open for this chat yet. The next call opens it in the right sidebar.'
|
|
117
|
-
const ELSEWHERE = 'This chat is not the one on screen. Open it, then call again.'
|
|
118
|
-
const NO_PANEL = 'The browser panel could not be taken here. Open the 内置浏览器 tab in the right sidebar, then call again.'
|
|
119
|
-
const PAGE_LOST = 'The page this chat was using is gone: the browser released it or replaced it, so nothing here can carry on in the same state. Call browser_navigate to open the address again as a new page, and read the state afresh.'
|
|
120
|
-
const LEGACY_PAGE = 'The window is still running the previous copy of this plugin. Reload the page once, then call again.'
|
|
121
|
-
const NOT_DRAWN = "This chat's page is not the one being read. Open this chat, then call again; browser_script reads the page as it is."
|
|
122
|
-
|
|
123
|
-
/** @type {string} */
|
|
124
|
-
let proofLog = process.env.DSH_INNER_BROWSER_PROOF ?? '/tmp/dsh-inner-browser-use.log'
|
|
28
|
+
/** Largest request body accepted. */
|
|
29
|
+
const MAX_BODY_BYTES = 1024 * 1024
|
|
125
30
|
|
|
126
31
|
/**
|
|
127
32
|
* Read a request body with a hard ceiling.
|
|
@@ -130,12 +35,13 @@ let proofLog = process.env.DSH_INNER_BROWSER_PROOF ?? '/tmp/dsh-inner-browser-us
|
|
|
130
35
|
*/
|
|
131
36
|
function readBody(req) {
|
|
132
37
|
return new Promise((resolve, reject) => {
|
|
38
|
+
/** @type {Buffer[]} */
|
|
133
39
|
const chunks = []
|
|
134
40
|
let size = 0
|
|
135
41
|
req.on('data', (chunk) => {
|
|
136
42
|
size += chunk.length
|
|
137
|
-
if (size >
|
|
138
|
-
reject(new Error('
|
|
43
|
+
if (size > MAX_BODY_BYTES) {
|
|
44
|
+
reject(new Error('body too large'))
|
|
139
45
|
req.destroy()
|
|
140
46
|
return
|
|
141
47
|
}
|
|
@@ -147,807 +53,104 @@ function readBody(req) {
|
|
|
147
53
|
}
|
|
148
54
|
|
|
149
55
|
/**
|
|
150
|
-
* One Client connection
|
|
151
|
-
*
|
|
152
|
-
* seat when exactly one tab is open.
|
|
153
|
-
*/
|
|
154
|
-
/**
|
|
155
|
-
* The file a workspace's cookies live in. The key is a workspace name, so the user
|
|
156
|
-
* can delete one file and sign in again for that workspace only.
|
|
157
|
-
* @param {string} key - workspace key the guest was leased with.
|
|
158
|
-
* @returns {string} absolute path of its cookie file.
|
|
159
|
-
*/
|
|
160
|
-
/**
|
|
161
|
-
* The folder a chat's files land under: its project, else the folder the run
|
|
162
|
-
* works in, else the folder the person's account calls home, so a chat that
|
|
163
|
-
* named no project still leaves a file where it can be found.
|
|
164
|
-
* @param {any} bridge - the bridge, for the folders a chat already named.
|
|
165
|
-
* @param {string} chat - chat asking.
|
|
166
|
-
* @param {string} [agentCwd] - folder the run works in, when it has one.
|
|
167
|
-
* @returns {string} absolute folder.
|
|
168
|
-
*/
|
|
169
|
-
export function workBase(bridge, chat, agentCwd) {
|
|
170
|
-
const known = bridge?.cwdOf?.(chat)
|
|
171
|
-
if (String(known ?? '') !== '') return String(known)
|
|
172
|
-
if (String(agentCwd ?? '') !== '') return String(agentCwd)
|
|
173
|
-
const named = bridge?.folderOf?.(chat)
|
|
174
|
-
if (String(named ?? '') !== '') return String(named)
|
|
175
|
-
const home = homedir()
|
|
176
|
-
return String(home ?? '') === '' ? process.cwd() : home
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function cookiePath(key) {
|
|
180
|
-
const digest = createHash('sha256').update(String(key)).digest('hex').slice(0, 24)
|
|
181
|
-
return join(cookieDir(), `${digest}.json`)
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Keep only what a cookie is: a name, a value, and where it belongs. Anything else
|
|
186
|
-
* a page reports is dropped rather than handed back to the guest.
|
|
187
|
-
* @param {unknown} list - what the page sent.
|
|
188
|
-
* @returns {Record<string, unknown>[]} cookie fields worth keeping.
|
|
189
|
-
*/
|
|
190
|
-
export function keptCookies(list) {
|
|
191
|
-
if (!Array.isArray(list)) return []
|
|
192
|
-
const out = []
|
|
193
|
-
for (const each of list) {
|
|
194
|
-
if (each === null || typeof each !== 'object') continue
|
|
195
|
-
const one = /** @type {Record<string, unknown>} */ (each)
|
|
196
|
-
if (typeof one.name !== 'string' || typeof one.value !== 'string') continue
|
|
197
|
-
if (typeof one.domain !== 'string' && typeof one.url !== 'string') continue
|
|
198
|
-
out.push(one)
|
|
199
|
-
if (out.length >= MAX_COOKIES) break
|
|
200
|
-
}
|
|
201
|
-
return out
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Read the cookies kept for a workspace.
|
|
206
|
-
* @param {string} key - workspace key.
|
|
207
|
-
* @returns {Promise<Record<string, unknown>[]>} cookies, empty when none were kept.
|
|
208
|
-
*/
|
|
209
|
-
export async function readCookies(key) {
|
|
210
|
-
try {
|
|
211
|
-
const text = await readFile(cookiePath(key), 'utf8')
|
|
212
|
-
if (text.length > MAX_COOKIE_BYTES) return []
|
|
213
|
-
return keptCookies(JSON.parse(text))
|
|
214
|
-
} catch {
|
|
215
|
-
return []
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Replace the cookies kept for a workspace.
|
|
221
|
-
* @param {string} key - workspace key.
|
|
222
|
-
* @param {unknown} list - cookies the page reported.
|
|
223
|
-
* @returns {Promise<number>} how many were kept.
|
|
224
|
-
*/
|
|
225
|
-
export async function writeCookies(key, list) {
|
|
226
|
-
const kept = keptCookies(list)
|
|
227
|
-
await mkdir(cookieDir(), { recursive: true })
|
|
228
|
-
const file = cookiePath(key)
|
|
229
|
-
if (kept.length === 0) {
|
|
230
|
-
await writeFile(file, '[]', { mode: 0o600 })
|
|
231
|
-
return 0
|
|
232
|
-
}
|
|
233
|
-
const text = JSON.stringify(kept)
|
|
234
|
-
if (text.length > MAX_COOKIE_BYTES) return 0
|
|
235
|
-
await writeFile(file, text, { mode: 0o600 })
|
|
236
|
-
await chmod(file, 0o600).catch(() => {})
|
|
237
|
-
return kept.length
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* One heartbeat as the state route reports it: the page half is fresh while it
|
|
242
|
-
* answered within `CLIENT_TTL_MS`, and a stale beat is kept, not erased — it is
|
|
243
|
-
* the only clue that a page loaded earlier and then stopped.
|
|
244
|
-
* @param {{ms: number, frame: Record<string, unknown>} | undefined} beat - last heartbeat.
|
|
245
|
-
* @param {number} [now] - current time in ms.
|
|
246
|
-
* @returns {Record<string, unknown> | null} - the reported client, or null before any beat.
|
|
56
|
+
* One Client connection per chat: the tab that receives pushes for it.
|
|
57
|
+
* Nothing else lives here; the browser itself is the Host's. // ui-copy-ignore
|
|
247
58
|
*/
|
|
248
|
-
export function clientState(beat, now = Date.now()) {
|
|
249
|
-
if (beat === undefined) return null
|
|
250
|
-
return { ...beat.frame, at: new Date(beat.ms).toISOString(), fresh: now - beat.ms < CLIENT_TTL_MS }
|
|
251
|
-
}
|
|
252
|
-
|
|
253
59
|
export class Bridge {
|
|
254
60
|
constructor() {
|
|
255
|
-
/** @type {Map<string,
|
|
61
|
+
/** @type {Map<string, import('node:http').ServerResponse>} */
|
|
256
62
|
this.seats = new Map()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
this.refused = new Map()
|
|
261
|
-
/** @type {{at: string, frame: unknown} | undefined} */
|
|
262
|
-
this.client = undefined
|
|
263
|
-
/** The last few beats, so a page that fell over keeps what it said on the way.
|
|
264
|
-
* @type {Array<{at: string, frame: any}>} */
|
|
265
|
-
this.trail = []
|
|
266
|
-
/** The decisions this bridge made, newest last: which chat asked, which tab
|
|
267
|
-
* answered, who was refused. A wrong page is traced from here, not guessed.
|
|
268
|
-
* @type {Array<{at: string, what: string, chat?: string, reason?: string}>} */
|
|
269
|
-
this.events = []
|
|
270
|
-
/** Whether the page said it drives the product's own browser; undefined until it says. @type {boolean | undefined} */
|
|
271
|
-
this.native = undefined
|
|
272
|
-
/** @type {Set<import('node:http').ServerResponse>} open watch streams: the page's listener that can open tabs. */
|
|
273
|
-
this.watchers = new Set()
|
|
274
|
-
/** When each chat last asked for a tab, so one ask per call burst. @type {Map<string, number>} */
|
|
275
|
-
this.asked = new Map()
|
|
276
|
-
/** Named in every heartbeat reply: a page whose stream predates this bridge
|
|
277
|
-
* still looks connected and reaches nothing, so the reply tells it to reopen. */
|
|
278
|
-
this.epoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
279
|
-
/** @type {{chat: string, at: number} | undefined} the ask still waiting for a tab. */
|
|
280
|
-
this.asking = undefined
|
|
281
|
-
/** @type {number} how long a call waits for the tab it asked for. */
|
|
282
|
-
this.openWaitMs = OPEN_WAIT_MS
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Take one watch stream: the Client half that can open a tab on request.
|
|
287
|
-
* @param {import('node:http').ServerResponse} res - open SSE response.
|
|
288
|
-
*/
|
|
289
|
-
watch(res) {
|
|
290
|
-
this.watchers.add(res)
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Learn which tab answers this chat: the page reports the id it opened right
|
|
295
|
-
* after the ask, and that id is what the tab's own stream arrives under.
|
|
296
|
-
* @param {Record<string, unknown>} frame - one heartbeat.
|
|
297
|
-
*/
|
|
298
|
-
/**
|
|
299
|
-
* A page that will not answer this chat says why, so the call need not wait.
|
|
300
|
-
* @param {{phase?: string, step?: string, session?: string}} frame - heartbeat from the page.
|
|
301
|
-
* @returns {void}
|
|
302
|
-
*/
|
|
303
|
-
note(what, detail = {}) {
|
|
304
|
-
this.events.push({ at: new Date().toISOString(), what, ...detail })
|
|
305
|
-
if (this.events.length > EVENT_KEEP) this.events.splice(0, this.events.length - EVENT_KEEP)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
noteStep(frame) {
|
|
309
|
-
if (frame?.phase !== 'open' || typeof frame.session !== 'string' || frame.session === '') return
|
|
310
|
-
if (frame.step === 'other-chat') this.refused.set(frame.session, ELSEWHERE)
|
|
311
|
-
if (frame.step === 'rightbar') this.refused.set(frame.session, NO_PANEL)
|
|
312
|
-
if (frame.step === 'page-lost') this.refused.set(frame.session, PAGE_LOST)
|
|
313
|
-
/* A page that opened answers with the tab it opened; only a refusal is news. */
|
|
314
|
-
if (frame.step === undefined) return
|
|
315
|
-
this.note('refused', { chat: frame.session, reason: String(frame.step) })
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
noteOpen(frame) {
|
|
319
|
-
const asking = this.asking
|
|
320
|
-
if (asking === undefined) return
|
|
321
|
-
/* Only an answer that says what it opened is an answer; the page also
|
|
322
|
-
reports tab housekeeping under the same phase, and that is not a reply. */
|
|
323
|
-
const opened = frame?.opened
|
|
324
|
-
if (opened === undefined || opened === null || opened === '') return
|
|
325
|
-
if (opened !== 'current' && opened !== 'session') {
|
|
326
|
-
this.note('open-ignored', { chat: asking.chat, reason: String(opened) })
|
|
327
|
-
return
|
|
328
|
-
}
|
|
329
|
-
/* The answer names the chat it opened for, and only that chat may take it:
|
|
330
|
-
pairing by arrival time is how one chat's call was once answered with
|
|
331
|
-
another chat's tab. */
|
|
332
|
-
if (String(frame?.session ?? '') !== asking.chat) {
|
|
333
|
-
this.note('open-rejected', { chat: asking.chat, reason: `answer named ${String(frame?.session ?? '')}` })
|
|
334
|
-
return
|
|
335
|
-
}
|
|
336
|
-
const req = String(frame?.req ?? '')
|
|
337
|
-
if (req !== '' && asking.req !== '' && req !== asking.req) {
|
|
338
|
-
this.note('open-rejected', { chat: asking.chat, reason: 'answer for another ask' })
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
/* The page that opened it attaches its own stream; the call waits for that
|
|
342
|
-
seat rather than one being invented from the answer. */
|
|
343
|
-
this.asking = undefined
|
|
344
|
-
this.note('open-answered', { chat: asking.chat })
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** @param {import('node:http').ServerResponse} res - closed SSE response. */
|
|
348
|
-
unwatch(res) {
|
|
349
|
-
this.watchers.delete(res)
|
|
63
|
+
/* The one chat that owns the mirror. The column is global, so the picture
|
|
64
|
+
may only ever be drawn for this chat; every other chat shows a note. */
|
|
65
|
+
this.owner = ''
|
|
350
66
|
}
|
|
351
67
|
|
|
352
68
|
/**
|
|
353
|
-
*
|
|
354
|
-
* @param {string} sessionId - chat that
|
|
69
|
+
* Give the mirror to one chat and take it away from every earlier one.
|
|
70
|
+
* @param {string} sessionId - chat that asked for it.
|
|
355
71
|
*/
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const last = this.asked.get(sessionId)
|
|
359
|
-
if (last !== undefined && now - last < ASK_GAP_MS) return
|
|
360
|
-
if (this.watchers.size === 0) return
|
|
361
|
-
this.asked.set(sessionId, now)
|
|
362
|
-
this.note('asked', { chat: sessionId, reason: page === '' ? 'no address' : page.slice(0, 120) })
|
|
363
|
-
const req = `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
364
|
-
this.asking = { chat: sessionId, at: now, page, req }
|
|
365
|
-
const frame = `data: ${JSON.stringify({ type: 'wanted', session: sessionId, url: page, req })}\n\n`
|
|
366
|
-
for (const res of this.watchers) res.write(frame)
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** @param {string} sessionId - chat asking. @returns {object | undefined} the tab that can take it. */
|
|
370
|
-
seatFor(sessionId) {
|
|
371
|
-
/* A tab belongs to the chat that opened it. Handing one chat's call to
|
|
372
|
-
another chat's tab steers a page the person is reading, so a chat with no
|
|
373
|
-
tab of its own waits for its own instead of borrowing one. */
|
|
374
|
-
return this.seats.get(sessionId)
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
/** @param {string} sessionId - chat asking. @returns {boolean} whether a tab can take the call now. */
|
|
378
|
-
hasSeat(sessionId) {
|
|
379
|
-
return this.seatFor(sessionId) !== undefined
|
|
72
|
+
claim(sessionId) {
|
|
73
|
+
if (sessionId !== '') this.owner = sessionId
|
|
380
74
|
}
|
|
381
75
|
|
|
382
76
|
/**
|
|
383
|
-
*
|
|
384
|
-
* @param {string} sessionId - chat
|
|
385
|
-
* @param {number} ms - ceiling.
|
|
386
|
-
* @param {AbortSignal | undefined} signal - tool cancellation.
|
|
387
|
-
* @returns {Promise<boolean>} whether a tab arrived.
|
|
388
|
-
*/
|
|
389
|
-
waitSeat(sessionId, ms, signal) {
|
|
390
|
-
if (this.hasSeat(sessionId)) return Promise.resolve(true)
|
|
391
|
-
if (ms <= 0) return Promise.resolve(false)
|
|
392
|
-
return new Promise((resolve) => {
|
|
393
|
-
const done = (value) => {
|
|
394
|
-
clearInterval(poll)
|
|
395
|
-
signal?.removeEventListener('abort', abort)
|
|
396
|
-
resolve(value)
|
|
397
|
-
}
|
|
398
|
-
const poll = setInterval(() => {
|
|
399
|
-
if (this.hasSeat(sessionId)) done(true)
|
|
400
|
-
else if (this.refused.has(sessionId)) done(false)
|
|
401
|
-
}, 120)
|
|
402
|
-
poll.unref?.()
|
|
403
|
-
const abort = () => done(false)
|
|
404
|
-
signal?.addEventListener('abort', abort)
|
|
405
|
-
setTimeout(() => done(this.hasSeat(sessionId)), ms).unref?.()
|
|
406
|
-
})
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* @param {string} sessionId - owning chat.
|
|
77
|
+
* Take the chat's event stream.
|
|
78
|
+
* @param {string} sessionId - chat the tab belongs to.
|
|
411
79
|
* @param {import('node:http').ServerResponse} res - open SSE response.
|
|
412
|
-
* @param {string} workspace - lease key of the chat's workspace, as the tab reports it.
|
|
413
|
-
*/
|
|
414
|
-
attach(sessionId, res, workspace) {
|
|
415
|
-
this.note('attached', { chat: sessionId, reason: String(workspace ?? '') })
|
|
416
|
-
const previous = this.seats.get(sessionId)
|
|
417
|
-
if (previous !== undefined && previous.res !== res) previous.res.end()
|
|
418
|
-
this.seats.set(sessionId, { res, sessionId, workspace })
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Make a folder under the chat's work root, keeping it out of the project's git.
|
|
423
|
-
* @param {string} base - chat's working directory.
|
|
424
|
-
* @param {string} inner - folder under the work root.
|
|
425
|
-
* @returns {Promise<string>} absolute folder to write into.
|
|
426
80
|
*/
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
await writeFile(join(base, WORK_DIR, '.gitignore'), KEEP).catch(() => {})
|
|
431
|
-
return dir
|
|
81
|
+
attach(sessionId, res) {
|
|
82
|
+
this.seats.get(sessionId)?.end()
|
|
83
|
+
this.seats.set(sessionId, res)
|
|
432
84
|
}
|
|
433
85
|
|
|
434
86
|
/**
|
|
435
|
-
*
|
|
436
|
-
* @param {string} sessionId - chat
|
|
437
|
-
* @
|
|
87
|
+
* Drop a stream when its chat or its tab goes away.
|
|
88
|
+
* @param {string} sessionId - chat the tab belonged to.
|
|
89
|
+
* @param {import('node:http').ServerResponse} res - response being closed.
|
|
438
90
|
*/
|
|
439
|
-
cwdOf(sessionId) {
|
|
440
|
-
const key = String(this.seatFor(sessionId)?.workspace ?? '')
|
|
441
|
-
if (key.startsWith('cwd:') !== true) return undefined
|
|
442
|
-
const dir = key.slice(4)
|
|
443
|
-
return dir === '' ? undefined : dir
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
folderOf(sessionId) {
|
|
447
|
-
try {
|
|
448
|
-
const dir = String(this.hostFolder?.(sessionId) ?? '')
|
|
449
|
-
return dir === '' ? undefined : dir
|
|
450
|
-
} catch {
|
|
451
|
-
return undefined
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** @param {string} sessionId - owning chat. @param {import('node:http').ServerResponse} res - response being closed. */
|
|
456
91
|
detach(sessionId, res) {
|
|
457
|
-
|
|
458
|
-
if (seat !== undefined && seat.res === res) {
|
|
459
|
-
this.note('detached', { chat: sessionId })
|
|
460
|
-
this.seats.delete(sessionId)
|
|
461
|
-
/* The page that answers next may be a different copy. */
|
|
462
|
-
this.native = undefined
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** @returns {string[]} chats with a live tab. */
|
|
467
|
-
connected() {
|
|
468
|
-
return [...this.seats.keys()]
|
|
92
|
+
if (this.seats.get(sessionId) === res) this.seats.delete(sessionId)
|
|
469
93
|
}
|
|
470
94
|
|
|
471
95
|
/**
|
|
472
|
-
* Push one frame to a
|
|
96
|
+
* Push one frame to a chat's tab.
|
|
473
97
|
* @param {string} sessionId - target chat.
|
|
474
98
|
* @param {unknown} frame - JSON frame.
|
|
475
99
|
* @returns {boolean} whether a tab received it.
|
|
476
100
|
*/
|
|
477
101
|
push(sessionId, frame) {
|
|
478
|
-
const seat = this.
|
|
102
|
+
const seat = this.seats.get(sessionId)
|
|
479
103
|
if (seat === undefined) return false
|
|
480
|
-
seat.
|
|
104
|
+
seat.write(`data: ${JSON.stringify(frame)}\n\n`)
|
|
481
105
|
return true
|
|
482
106
|
}
|
|
483
107
|
|
|
484
|
-
/**
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
* @param {string} tool - operation name the Client understands.
|
|
488
|
-
* @param {Record<string, unknown>} args - operation arguments.
|
|
489
|
-
* @param {AbortSignal | undefined} signal - cancellation from the tool layer.
|
|
490
|
-
* @param {number} timeoutMs - how long the operation may take.
|
|
491
|
-
* @returns {Promise<unknown>} the Client's reply value.
|
|
492
|
-
*/
|
|
493
|
-
async call(sessionId, tool, args, signal, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
494
|
-
if (this.native === false) throw new Error(LEGACY_PAGE)
|
|
495
|
-
if (!this.hasSeat(sessionId)) {
|
|
496
|
-
this.refused.delete(sessionId)
|
|
497
|
-
/* The tab opens at the address the call needs, so a chat never gets an
|
|
498
|
-
empty browser beside the page it asked for. */
|
|
499
|
-
this.want(sessionId, typeof args?.url === 'string' ? args.url : '')
|
|
500
|
-
await this.waitSeat(sessionId, this.openWaitMs, signal)
|
|
501
|
-
/* A page that cannot come says so at once, instead of letting the call
|
|
502
|
-
wait out the whole opening window. */
|
|
503
|
-
if (!this.hasSeat(sessionId)) {
|
|
504
|
-
const why = this.refused.get(sessionId)
|
|
505
|
-
if (why !== undefined) throw new Error(why)
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
try {
|
|
509
|
-
return await this.attempt(sessionId, tool, args, signal, timeoutMs)
|
|
510
|
-
} catch (error) {
|
|
511
|
-
/* One retry after the page came back. */
|
|
512
|
-
if (!String(error?.message ?? error).includes('must be attached to the DOM')) throw error
|
|
513
|
-
await new Promise((resolve) => setTimeout(resolve, 600))
|
|
514
|
-
return this.attempt(sessionId, tool, args, signal, timeoutMs)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* One round trip to the page.
|
|
520
|
-
* @param {string} sessionId - target chat.
|
|
521
|
-
* @param {string} tool - operation the Client understands.
|
|
522
|
-
* @param {Record<string, unknown>} args - operation arguments.
|
|
523
|
-
* @param {AbortSignal | undefined} signal - cancellation from the tool layer.
|
|
524
|
-
* @param {number} timeoutMs - how long the operation may take.
|
|
525
|
-
* @returns {Promise<unknown>} the Client's reply value.
|
|
526
|
-
*/
|
|
527
|
-
async attempt(sessionId, tool, args, signal, timeoutMs) {
|
|
528
|
-
/* A picture comes from a painted surface, and the surface belongs to the chat
|
|
529
|
-
being read. Asking for one from elsewhere would have the page show itself,
|
|
530
|
-
which takes the column away from whoever is reading; the answer names the
|
|
531
|
-
chat instead. */
|
|
532
|
-
if (tool === 'shot' && args?.offscreen !== true) {
|
|
533
|
-
const screen = typeof this.client?.frame?.session === 'string' ? this.client.frame.session : ''
|
|
534
|
-
if (screen !== '' && screen !== sessionId) throw new Error(NOT_DRAWN)
|
|
535
|
-
}
|
|
536
|
-
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
537
|
-
if (!this.push(sessionId, { type: 'call', id, tool, args, session: sessionId })) {
|
|
538
|
-
this.note('call-no-tab', { chat: sessionId, reason: tool })
|
|
539
|
-
throw new Error(NO_TAB)
|
|
540
|
-
}
|
|
541
|
-
this.note('called', { chat: sessionId, reason: tool })
|
|
542
|
-
return new Promise((resolve, reject) => {
|
|
543
|
-
const timer = setTimeout(() => {
|
|
544
|
-
this.waiters.delete(id)
|
|
545
|
-
reject(new Error(`the page did not finish ${tool} in time`))
|
|
546
|
-
}, timeoutMs)
|
|
547
|
-
timer.unref?.()
|
|
548
|
-
this.waiters.set(id, { resolve, reject, timer })
|
|
549
|
-
signal?.addEventListener('abort', () => {
|
|
550
|
-
const waiter = this.waiters.get(id)
|
|
551
|
-
if (waiter === undefined) return
|
|
552
|
-
clearTimeout(waiter.timer)
|
|
553
|
-
this.waiters.delete(id)
|
|
554
|
-
reject(new Error('cancelled'))
|
|
555
|
-
}, { once: true })
|
|
556
|
-
})
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Resolve the waiter a reply frame belongs to.
|
|
561
|
-
* @param {{id?: string, ok?: boolean, value?: unknown, error?: string}} frame - reply frame.
|
|
562
|
-
* @returns {boolean} whether a waiter took it.
|
|
563
|
-
*/
|
|
564
|
-
settle(frame) {
|
|
565
|
-
if (typeof frame.id !== 'string') return false
|
|
566
|
-
const waiter = this.waiters.get(frame.id)
|
|
567
|
-
if (waiter === undefined) return false
|
|
568
|
-
this.waiters.delete(frame.id)
|
|
569
|
-
clearTimeout(waiter.timer)
|
|
570
|
-
if (frame.ok === false) waiter.reject(new Error(frame.error ?? 'the page refused the call'))
|
|
571
|
-
else waiter.resolve(frame.value)
|
|
572
|
-
return true
|
|
108
|
+
/** @returns {string[]} chats with a live tab. */
|
|
109
|
+
connected() {
|
|
110
|
+
return [...this.seats.keys()]
|
|
573
111
|
}
|
|
574
112
|
|
|
575
|
-
/** Close every
|
|
113
|
+
/** Close every stream. */
|
|
576
114
|
dispose() {
|
|
577
|
-
for (const
|
|
115
|
+
for (const res of this.seats.values()) res.end()
|
|
578
116
|
this.seats.clear()
|
|
579
|
-
for (const waiter of this.waiters.values()) {
|
|
580
|
-
clearTimeout(waiter.timer)
|
|
581
|
-
waiter.reject(new Error('plugin stopped'))
|
|
582
|
-
}
|
|
583
|
-
this.waiters.clear()
|
|
584
|
-
for (const res of this.watchers) res.end()
|
|
585
|
-
this.watchers.clear()
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** @returns {Promise<void>} resolves once the proof line is on disk. */
|
|
590
|
-
async function record(line) {
|
|
591
|
-
const { appendFile } = await import('node:fs/promises')
|
|
592
|
-
await appendFile(proofLog, `${JSON.stringify({ at: new Date().toISOString(), ...line })}\n`).catch(() => {})
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* Wrap one Client operation as a tool result.
|
|
597
|
-
* @param {string} text - what the model reads.
|
|
598
|
-
* @returns {{text: string}} tool value.
|
|
599
|
-
*/
|
|
600
|
-
/**
|
|
601
|
-
* Turn base64 into bytes without naming a global the tool context may hide.
|
|
602
|
-
* @param {string} value - base64 text.
|
|
603
|
-
* @returns {Uint8Array} bytes a write accepts.
|
|
604
|
-
*/
|
|
605
|
-
function decodeBase64(value) {
|
|
606
|
-
const binary = atob(String(value))
|
|
607
|
-
const bytes = new Uint8Array(binary.length)
|
|
608
|
-
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
609
|
-
return bytes
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function asValue(text) {
|
|
613
|
-
return { text }
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
/** The largest saved file the chat reads back in the same result. */
|
|
618
|
-
const INLINE_BYTES = 200_000 // ui-copy-ignore
|
|
619
|
-
/** The longest text the chat reads back in the same result. */
|
|
620
|
-
const INLINE_CHARS = 6_000 // ui-copy-ignore
|
|
621
|
-
|
|
622
|
-
/**
|
|
623
|
-
* Read back a saved file when it is small and holds text, so the chat can quote
|
|
624
|
-
* it straight away. A picture, an archive or a big file stays on disk.
|
|
625
|
-
* @param {string} file - absolute path of the saved file.
|
|
626
|
-
* @returns {Promise<string>} text to append, empty when the file is not text.
|
|
627
|
-
*/
|
|
628
|
-
async function readableTail(file) {
|
|
629
|
-
if (file === '') return ''
|
|
630
|
-
const info = await stat(file).catch(() => undefined)
|
|
631
|
-
if (info === undefined || info.isFile() !== true || info.size === 0 || info.size > INLINE_BYTES) return ''
|
|
632
|
-
const raw = await readFile(file).catch(() => undefined)
|
|
633
|
-
if (raw === undefined) return ''
|
|
634
|
-
if (raw.subarray(0, 512).includes(0) === true) return '' // ui-copy-ignore
|
|
635
|
-
const text = raw.toString('utf8')
|
|
636
|
-
if (text.includes('\uFFFD') === true) return ''
|
|
637
|
-
const kept = text.length > INLINE_CHARS ? `${text.slice(0, INLINE_CHARS)}\n…` : text
|
|
638
|
-
return `\n\n${kept}`
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/** @param {string | null} raw - a name the page offered. @returns {string} one safe path segment. */
|
|
642
|
-
function safeName(raw) {
|
|
643
|
-
const flat = String(raw ?? '').replace(/\\/g, '/').split('/').pop() ?? ''
|
|
644
|
-
const clean = flat.replace(/[\u0000-\u001f<>:"|?*]/g, '').trim().slice(0, 120)
|
|
645
|
-
return clean === '' || clean === '.' || clean === '..' ? 'download' : clean
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* A name the page asked for, unless the folder already holds one.
|
|
650
|
-
* @param {string} dir - target folder.
|
|
651
|
-
* @param {string} name - wanted file name.
|
|
652
|
-
* @returns {Promise<string>} a path no other file holds.
|
|
653
|
-
*/
|
|
654
|
-
async function freePath(dir, name) {
|
|
655
|
-
const dot = name.lastIndexOf('.')
|
|
656
|
-
const stem = dot > 0 ? name.slice(0, dot) : name
|
|
657
|
-
const ext = dot > 0 ? name.slice(dot) : ''
|
|
658
|
-
for (let index = 1; index < 1000; index += 1) {
|
|
659
|
-
const candidate = join(dir, index === 1 ? name : `${stem}-${index}${ext}`)
|
|
660
|
-
if ((await stat(candidate).catch(() => undefined)) === undefined) return candidate
|
|
661
117
|
}
|
|
662
|
-
return join(dir, `${stem}-${Date.now()}${ext}`)
|
|
663
118
|
}
|
|
664
119
|
|
|
665
|
-
/**
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
render(_args, value) {
|
|
670
|
-
return [{ type: 'text', text: String(value?.text ?? '') }]
|
|
671
|
-
},
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/**
|
|
675
|
-
* Build the tool definitions over one bridge.
|
|
676
|
-
* @param {Bridge} bridge - the live bridge.
|
|
677
|
-
* @param {Map<string, { dir: string, file: string }>} sinks - folder per live file transfer.
|
|
678
|
-
* @returns {Array<Record<string, unknown>>} tool definitions.
|
|
679
|
-
*/
|
|
680
|
-
function tools(bridge, sinks) {
|
|
681
|
-
/**
|
|
682
|
-
* Run one Client operation for the calling chat.
|
|
683
|
-
* @param {string} tool - Client operation.
|
|
684
|
-
* @param {unknown} args - arguments.
|
|
685
|
-
* @param {any} exec - tool run context.
|
|
686
|
-
* @param {number} timeoutMs - operation budget.
|
|
687
|
-
* @returns {Promise<{text: string}>} rendered value.
|
|
688
|
-
*/
|
|
689
|
-
const run = async (tool, args, exec, timeoutMs) => {
|
|
690
|
-
/* A call without the identity of the chat it belongs to cannot be placed:
|
|
691
|
-
guessing one is how a page from another chat got driven. */
|
|
692
|
-
const chat = String(exec.agent?.id ?? '')
|
|
693
|
-
if (chat === '') throw new Error(NO_CHAT)
|
|
694
|
-
const value = await bridge.call(chat, tool, args ?? {}, exec.signal, timeoutMs)
|
|
695
|
-
return asValue(typeof value === 'string' ? value : (value?.text ?? JSON.stringify(value ?? null)))
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
return [
|
|
699
|
-
{
|
|
700
|
-
name: 'browser_navigate', // ui-copy-ignore
|
|
701
|
-
description: 'Open a URL in the sidebar browser of this chat and return the page it shows.',
|
|
702
|
-
parameters: {
|
|
703
|
-
type: 'object', // ui-copy-ignore
|
|
704
|
-
properties: { url: { type: 'string', description: 'Absolute http or https URL.' } },
|
|
705
|
-
required: ['url'], // ui-copy-ignore
|
|
706
|
-
additionalProperties: false,
|
|
707
|
-
},
|
|
708
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
709
|
-
timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
|
|
710
|
-
isConcurrencySafe: () => false,
|
|
711
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
|
|
712
|
-
execute: (args, exec) => run('navigate', args, exec, NAVIGATE_TIMEOUT_MS),
|
|
713
|
-
},
|
|
714
|
-
{
|
|
715
|
-
name: 'browser_view', // ui-copy-ignore
|
|
716
|
-
description: 'Return what the sidebar browser of this chat shows now: address, title, visible text, and numbered controls.',
|
|
717
|
-
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
718
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
719
|
-
isConcurrencySafe: () => false,
|
|
720
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
|
|
721
|
-
execute: (args, exec) => run('view', args, exec, DEFAULT_TIMEOUT_MS),
|
|
722
|
-
},
|
|
723
|
-
{
|
|
724
|
-
name: 'browser_click', // ui-copy-ignore
|
|
725
|
-
description: 'Click one numbered control in the sidebar browser of this chat and return the page it leads to.',
|
|
726
|
-
parameters: {
|
|
727
|
-
type: 'object', // ui-copy-ignore
|
|
728
|
-
properties: { ref: { type: 'integer', description: 'Number shown by the latest browser_view.' } },
|
|
729
|
-
required: ['ref'], // ui-copy-ignore
|
|
730
|
-
additionalProperties: false,
|
|
731
|
-
},
|
|
732
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
733
|
-
timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
|
|
734
|
-
isConcurrencySafe: () => false,
|
|
735
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
|
|
736
|
-
execute: (args, exec) => run('click', args, exec, NAVIGATE_TIMEOUT_MS),
|
|
737
|
-
},
|
|
738
|
-
{
|
|
739
|
-
name: 'browser_type', // ui-copy-ignore
|
|
740
|
-
description: 'Type into one numbered field in the sidebar browser of this chat, optionally pressing Enter.',
|
|
741
|
-
parameters: {
|
|
742
|
-
type: 'object', // ui-copy-ignore
|
|
743
|
-
properties: {
|
|
744
|
-
ref: { type: 'integer', description: 'Number shown by the latest browser_view.' },
|
|
745
|
-
text: { type: 'string', description: 'Text to enter. Replaces the field content.' },
|
|
746
|
-
submit: { type: 'boolean', description: 'Press Enter after typing.' },
|
|
747
|
-
},
|
|
748
|
-
required: ['ref', 'text'], // ui-copy-ignore
|
|
749
|
-
additionalProperties: false,
|
|
750
|
-
},
|
|
751
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
752
|
-
timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
|
|
753
|
-
isConcurrencySafe: () => false,
|
|
754
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} page state. */
|
|
755
|
-
execute: (args, exec) => run('type', args, exec, NAVIGATE_TIMEOUT_MS),
|
|
756
|
-
},
|
|
757
|
-
{
|
|
758
|
-
name: 'browser_selection', // ui-copy-ignore
|
|
759
|
-
description: 'Return the text selected in the sidebar browser of this chat, so it can be quoted here.',
|
|
760
|
-
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
761
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
762
|
-
isConcurrencySafe: () => false,
|
|
763
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} the selection. */
|
|
764
|
-
execute: (args, exec) => run('selection', args, exec, DEFAULT_TIMEOUT_MS),
|
|
765
|
-
},
|
|
766
|
-
{
|
|
767
|
-
name: 'browser_copy', // ui-copy-ignore
|
|
768
|
-
description: 'Copy the page selection, or one numbered control\'s text, to the clipboard so it can be pasted into this chat.',
|
|
769
|
-
parameters: {
|
|
770
|
-
type: 'object', // ui-copy-ignore
|
|
771
|
-
properties: { ref: { type: 'integer', description: 'Number shown by the latest browser_view. Omit to copy the selection.' } },
|
|
772
|
-
additionalProperties: false,
|
|
773
|
-
},
|
|
774
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
775
|
-
isConcurrencySafe: () => false,
|
|
776
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} copy result. */
|
|
777
|
-
execute: (args, exec) => run('copy', args, exec, DEFAULT_TIMEOUT_MS),
|
|
778
|
-
},
|
|
779
|
-
{
|
|
780
|
-
name: 'browser_script', // ui-copy-ignore
|
|
781
|
-
description: 'Evaluate JavaScript in the sidebar browser of this chat and return its last expression. Use it when no control reaches the data you need.',
|
|
782
|
-
parameters: {
|
|
783
|
-
type: 'object', // ui-copy-ignore
|
|
784
|
-
properties: { code: { type: 'string', description: 'JavaScript source. The last expression is the result.' } },
|
|
785
|
-
required: ['code'], // ui-copy-ignore
|
|
786
|
-
additionalProperties: false,
|
|
787
|
-
},
|
|
788
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
789
|
-
timeoutMs: NAVIGATE_TIMEOUT_MS,
|
|
790
|
-
isConcurrencySafe: () => false,
|
|
791
|
-
/** @param {any} args - call arguments. @param {any} exec - run context. @returns {Promise<{text: string}>} result. */
|
|
792
|
-
execute: (args, exec) => run('script', args, exec, NAVIGATE_TIMEOUT_MS),
|
|
793
|
-
},
|
|
794
|
-
{
|
|
795
|
-
name: 'browser_screenshot',
|
|
796
|
-
description: 'Save a PNG of what the sidebar browser of this chat shows now, and return the file path.',
|
|
797
|
-
parameters: {
|
|
798
|
-
type: 'object', // ui-copy-ignore
|
|
799
|
-
properties: {
|
|
800
|
-
offscreen: {
|
|
801
|
-
type: 'boolean', // ui-copy-ignore
|
|
802
|
-
description: 'Take the image without bringing this chat to the front.',
|
|
803
|
-
},
|
|
804
|
-
},
|
|
805
|
-
additionalProperties: false,
|
|
806
|
-
}, // ui-copy-ignore
|
|
807
|
-
output: TEXT_OUTPUT,
|
|
808
|
-
isConcurrencySafe: () => false,
|
|
809
|
-
/**
|
|
810
|
-
* @param {any} args - call arguments.
|
|
811
|
-
* @param {any} exec - run context.
|
|
812
|
-
* @returns {Promise<{text: string}>} absolute path of the saved PNG.
|
|
813
|
-
*/
|
|
814
|
-
execute: async (args, exec) => {
|
|
815
|
-
const value = await bridge.call(
|
|
816
|
-
chatOf(exec),
|
|
817
|
-
'shot',
|
|
818
|
-
{ offscreen: args?.offscreen === true },
|
|
819
|
-
exec.signal,
|
|
820
|
-
DEFAULT_TIMEOUT_MS,
|
|
821
|
-
)
|
|
822
|
-
const png = value?.png
|
|
823
|
-
if (typeof png !== 'string' || png === '') throw new Error('the page returned no image')
|
|
824
|
-
const chat = chatOf(exec)
|
|
825
|
-
const base = workBase(bridge, chat, exec.agent?.cwd)
|
|
826
|
-
const dir = await bridge.room(base, SHOT_DIR)
|
|
827
|
-
const file = join(dir, `shot-${Date.now()}.png`)
|
|
828
|
-
await writeFile(file, decodeBase64(png))
|
|
829
|
-
return asValue(file)
|
|
830
|
-
},
|
|
831
|
-
},
|
|
832
|
-
{
|
|
833
|
-
name: 'browser_download', // ui-copy-ignore
|
|
834
|
-
description: "Save a file the sidebar browser of this chat offers into this chat's project folder and return its path.",
|
|
835
|
-
parameters: {
|
|
836
|
-
type: 'object', // ui-copy-ignore
|
|
837
|
-
properties: {
|
|
838
|
-
url: { type: 'string', description: 'Address of the file, when the page shows one.' },
|
|
839
|
-
ref: { type: 'integer', description: 'Number shown by the latest browser_view, for a control that links to the file.' },
|
|
840
|
-
name: { type: 'string', description: 'File name to save under, when the page offers none.' },
|
|
841
|
-
},
|
|
842
|
-
additionalProperties: false,
|
|
843
|
-
}, // ui-copy-ignore
|
|
844
|
-
output: TEXT_OUTPUT,
|
|
845
|
-
timeoutMs: DOWNLOAD_TIMEOUT_MS + 5_000,
|
|
846
|
-
isConcurrencySafe: () => false,
|
|
847
|
-
/**
|
|
848
|
-
* @param {any} args - call arguments.
|
|
849
|
-
* @param {any} exec - run context.
|
|
850
|
-
* @returns {Promise<{text: string}>} absolute path of the saved file.
|
|
851
|
-
*/
|
|
852
|
-
execute: async (args, exec) => {
|
|
853
|
-
const chat = chatOf(exec)
|
|
854
|
-
const base = workBase(bridge, chat, exec.agent?.cwd)
|
|
855
|
-
const dir = await bridge.room(base, DOWNLOAD_DIR)
|
|
856
|
-
/* The page never names a folder: this ticket is the one folder its bytes
|
|
857
|
-
may reach, and it dies with the call. */
|
|
858
|
-
const ticket = randomUUID() // ui-copy-ignore
|
|
859
|
-
sinks.set(ticket, { dir, file: '' })
|
|
860
|
-
try {
|
|
861
|
-
const value = await bridge.call(
|
|
862
|
-
chatOf(exec),
|
|
863
|
-
'download',
|
|
864
|
-
{ ticket, url: args?.url, ref: args?.ref, name: args?.name },
|
|
865
|
-
exec.signal,
|
|
866
|
-
DOWNLOAD_TIMEOUT_MS,
|
|
867
|
-
)
|
|
868
|
-
const saved = typeof value === 'string' ? value : String(value?.text ?? '')
|
|
869
|
-
const file = typeof value === 'object' && value !== null && typeof value.path === 'string' ? value.path : ''
|
|
870
|
-
const size = typeof value === 'object' && value !== null && Number.isFinite(value.bytes) ? value.bytes : undefined
|
|
871
|
-
const line = file === ''
|
|
872
|
-
? saved
|
|
873
|
-
: `Saved ${file} (${size ?? (await stat(file).catch(() => undefined))?.size ?? 0} bytes)`
|
|
874
|
-
/* A page that saved a readable file hands it over here as well, so the
|
|
875
|
-
chat can quote it without a second trip through the machine. */
|
|
876
|
-
return asValue(line + (await readableTail(file)))
|
|
877
|
-
} finally {
|
|
878
|
-
sinks.delete(ticket)
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
},
|
|
882
|
-
{
|
|
883
|
-
name: 'browser_upload', // ui-copy-ignore
|
|
884
|
-
description: 'Attach a file from this machine to one numbered file field in the sidebar browser of this chat.',
|
|
885
|
-
parameters: {
|
|
886
|
-
type: 'object', // ui-copy-ignore
|
|
887
|
-
properties: {
|
|
888
|
-
ref: { type: 'integer', description: 'Number shown by the latest browser_view.' },
|
|
889
|
-
path: { type: 'string', description: 'Absolute path of the file to attach.' },
|
|
890
|
-
},
|
|
891
|
-
required: ['ref', 'path'], // ui-copy-ignore
|
|
892
|
-
additionalProperties: false,
|
|
893
|
-
},
|
|
894
|
-
output: TEXT_OUTPUT, // ui-copy-ignore
|
|
895
|
-
timeoutMs: NAVIGATE_TIMEOUT_MS + 5_000,
|
|
896
|
-
isConcurrencySafe: () => false,
|
|
897
|
-
/**
|
|
898
|
-
* @param {any} args - call arguments.
|
|
899
|
-
* @param {any} exec - run context.
|
|
900
|
-
* @returns {Promise<{text: string}>} page state after the attach.
|
|
901
|
-
*/
|
|
902
|
-
execute: async (args, exec) => {
|
|
903
|
-
const file = resolve(String(args.path ?? ''))
|
|
904
|
-
const info = await stat(file).catch(() => undefined)
|
|
905
|
-
if (info === undefined || !info.isFile()) throw new Error(`no file at ${file}`)
|
|
906
|
-
if (info.size > MAX_UPLOAD_BYTES) throw new Error(`the file is ${info.size} bytes; the limit is ${MAX_UPLOAD_BYTES}`)
|
|
907
|
-
const bytes = await readFile(file)
|
|
908
|
-
const name = file.slice(file.lastIndexOf('/') + 1)
|
|
909
|
-
const value = await bridge.call(
|
|
910
|
-
chatOf(exec),
|
|
911
|
-
'upload',
|
|
912
|
-
{ ref: args.ref, name, type: mediaType(file), base64: bytes.toString('base64') },
|
|
913
|
-
exec.signal,
|
|
914
|
-
NAVIGATE_TIMEOUT_MS,
|
|
915
|
-
)
|
|
916
|
-
return asValue(typeof value === 'string' ? value : String(value?.text ?? ''))
|
|
917
|
-
},
|
|
918
|
-
},
|
|
919
|
-
]
|
|
920
|
-
}
|
|
120
|
+
/** Page read: address, title, controls, then text, as JSON the Host parses. */ // ui-copy-ignore
|
|
121
|
+
/* The read model and the tool surface live in their own files: this module is
|
|
122
|
+
the Host half (routes, bridge, lifetimes), not the model's vocabulary. */ // ui-copy-ignore
|
|
123
|
+
import { sidecarTools } from './tools.js?v=1'
|
|
921
124
|
|
|
922
125
|
export const name = 'dsh-inner-browser-use'
|
|
923
126
|
export const inject = ['webServer']
|
|
924
127
|
|
|
925
128
|
/**
|
|
926
|
-
* Mount the
|
|
129
|
+
* Mount the mirror routes and the tool surface.
|
|
927
130
|
* @param {any} ctx - plugin context.
|
|
928
|
-
* @param {{ proofLog?: string }} [config] - optional overrides.
|
|
929
131
|
*/
|
|
930
|
-
export function apply(ctx
|
|
931
|
-
if (typeof config.proofLog === 'string' && config.proofLog !== '') proofLog = config.proofLog
|
|
132
|
+
export function apply(ctx) {
|
|
932
133
|
const bridge = new Bridge()
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
|
|
134
|
+
/* The sidecar is a browser the Host owns outright: a real Chrome with its own // ui-copy-ignore
|
|
135
|
+
profile, shown in the column as a screencast. It outlives a plugin reload,
|
|
136
|
+
and it does not need the chat to be on screen to do work. */
|
|
137
|
+
const sidecar = new Sidecar({ logger: ctx.logger })
|
|
138
|
+
/** @type {{refs: any[], at: number, last?: any}} */
|
|
139
|
+
const sidecarRefs = { refs: [], at: 0 }
|
|
938
140
|
ctx.effect(() => () => bridge.dispose(), 'dsh-inner-browser-use: bridge lifetime')
|
|
939
|
-
/*
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
141
|
+
/* Closing the browser cleanly on quit is what keeps a sign-in alive: a
|
|
142
|
+
killed Chrome never writes the cookies a page set this minute. */
|
|
143
|
+
ctx.effect(() => {
|
|
144
|
+
const timer = setInterval(() => {
|
|
145
|
+
if (!sidecar.alive) return
|
|
146
|
+
void sidecar.saveSessionCookies()
|
|
147
|
+
}, 60_000)
|
|
148
|
+
timer.unref?.()
|
|
149
|
+
return () => {
|
|
150
|
+
clearInterval(timer)
|
|
151
|
+
void sidecar.shutdown().catch(() => {})
|
|
949
152
|
}
|
|
950
|
-
}
|
|
153
|
+
}, 'dsh-inner-browser-use: sidecar lifetime')
|
|
951
154
|
|
|
952
155
|
/** @param {import('node:http').IncomingMessage} req - request. @param {import('node:http').ServerResponse} res - response. */
|
|
953
156
|
const handler = async (req, res) => {
|
|
@@ -955,304 +158,211 @@ export function apply(ctx, config = {}) {
|
|
|
955
158
|
const path = url.pathname.slice(ROUTE.length) || '/'
|
|
956
159
|
const sessionId = url.searchParams.get('session') ?? ''
|
|
957
160
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
connection: 'keep-alive',
|
|
963
|
-
'x-accel-buffering': 'no',
|
|
964
|
-
})
|
|
965
|
-
res.write('retry: 2000\n\n')
|
|
966
|
-
bridge.attach(sessionId, res, url.searchParams.get('workspace') ?? '')
|
|
967
|
-
res.write(`data: ${JSON.stringify({ type: 'hello', session: sessionId, at: Date.now() })}\n\n`)
|
|
968
|
-
void record({ kind: 'downlink-open', session: sessionId, origin: req.headers.origin ?? null })
|
|
969
|
-
const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
|
|
970
|
-
beat.unref?.()
|
|
971
|
-
const close = () => {
|
|
972
|
-
clearInterval(beat)
|
|
973
|
-
bridge.detach(sessionId, res)
|
|
974
|
-
void record({ kind: 'downlink-close', session: sessionId })
|
|
975
|
-
}
|
|
976
|
-
req.on('close', close)
|
|
977
|
-
res.on('error', close)
|
|
978
|
-
return
|
|
161
|
+
/** @param {number} code - status. @param {unknown} value - body. */
|
|
162
|
+
const json = (code, value) => {
|
|
163
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
164
|
+
res.end(JSON.stringify(value))
|
|
979
165
|
}
|
|
980
166
|
|
|
981
|
-
if (path === '/
|
|
167
|
+
if (path === '/sidecar/frames' && req.method === 'GET') {
|
|
168
|
+
/* The column is global and only one chat may hold the mirror: a chat that
|
|
169
|
+
does not own it is refused here, so a stale tab cannot stream a picture
|
|
170
|
+
of somebody else's browser. */
|
|
171
|
+
if (bridge.owner !== '' && sessionId !== bridge.owner) {
|
|
172
|
+
json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
982
175
|
res.writeHead(200, {
|
|
983
176
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
984
177
|
'cache-control': 'no-cache, no-transform',
|
|
985
178
|
connection: 'keep-alive',
|
|
986
179
|
'x-accel-buffering': 'no',
|
|
987
180
|
})
|
|
181
|
+
res.socket?.setNoDelay?.(true)
|
|
988
182
|
res.write('retry: 2000\n\n')
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
const close = () => {
|
|
995
|
-
clearInterval(beat)
|
|
996
|
-
bridge.unwatch(res)
|
|
997
|
-
}
|
|
998
|
-
req.on('close', close)
|
|
999
|
-
res.on('error', close)
|
|
1000
|
-
return
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
if (path === '/uplink' && req.method === 'POST') {
|
|
1004
|
-
let frame
|
|
183
|
+
/** @type {() => void} */
|
|
184
|
+
let detach = () => {}
|
|
185
|
+
const drop = () => detach()
|
|
186
|
+
req.on('close', drop)
|
|
187
|
+
res.on('error', drop)
|
|
1005
188
|
try {
|
|
1006
|
-
|
|
189
|
+
detach = await sidecar.watch((frame) => {
|
|
190
|
+
/* A viewer that cannot keep up loses frames instead of making the
|
|
191
|
+
page wait: the newest picture is the only one worth sending. */
|
|
192
|
+
if (res.writableLength > 3 * 1024 * 1024) return
|
|
193
|
+
res.write(`data: ${JSON.stringify(frame)}\n\n`)
|
|
194
|
+
})
|
|
195
|
+
const info = await sidecar.state()
|
|
196
|
+
res.write(`data: ${JSON.stringify({ type: 'hello', ...info })}\n\n`)
|
|
1007
197
|
} catch (error) {
|
|
1008
|
-
res.
|
|
1009
|
-
res.end(String(error?.message ?? error))
|
|
1010
|
-
return
|
|
198
|
+
res.write(`data: ${JSON.stringify({ type: 'failed', text: String(error?.message ?? error) })}\n\n`)
|
|
1011
199
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
drives the product's browser is a copy loaded before that existed. */
|
|
1016
|
-
if (frame.phase === 'alive') bridge.native = frame.native === true
|
|
1017
|
-
bridge.client = { ms: Date.now(), frame }
|
|
1018
|
-
bridge.trail.push({ at: new Date().toISOString(), frame })
|
|
1019
|
-
if (bridge.trail.length > 24) bridge.trail.shift()
|
|
1020
|
-
bridge.noteOpen(frame)
|
|
1021
|
-
bridge.noteStep(frame)
|
|
1022
|
-
}
|
|
1023
|
-
bridge.settle(frame)
|
|
1024
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1025
|
-
res.end(JSON.stringify({ ok: true, epoch: bridge.epoch }))
|
|
200
|
+
const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
|
|
201
|
+
beat.unref?.()
|
|
202
|
+
req.on('close', () => clearInterval(beat))
|
|
1026
203
|
return
|
|
1027
204
|
}
|
|
1028
205
|
|
|
1029
|
-
if (path === '/
|
|
1030
|
-
|
|
1031
|
-
Gated so a page cannot be steered by another site. */
|
|
1032
|
-
if (req.headers['x-dshib-want'] !== '1') {
|
|
1033
|
-
res.writeHead(403, { 'content-type': 'text/plain' })
|
|
1034
|
-
res.end('forbidden')
|
|
1035
|
-
return
|
|
1036
|
-
}
|
|
1037
|
-
const asked = url.searchParams.get('session') ?? ''
|
|
1038
|
-
bridge.want(asked)
|
|
1039
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1040
|
-
res.end(JSON.stringify({ ok: true, session: asked, watchers: bridge.watchers.size }))
|
|
206
|
+
if (path === '/sidecar/state' && req.method === 'GET') {
|
|
207
|
+
json(200, { ...(await sidecar.state()), owner: bridge.owner })
|
|
1041
208
|
return
|
|
1042
209
|
}
|
|
1043
210
|
|
|
1044
|
-
if (path === '/
|
|
1045
|
-
let
|
|
211
|
+
if (path === '/sidecar/open' && req.method === 'POST') {
|
|
212
|
+
let body
|
|
1046
213
|
try {
|
|
1047
|
-
|
|
214
|
+
body = JSON.parse(await readBody(req))
|
|
215
|
+
} catch {
|
|
216
|
+
body = {}
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const address = String(body.url ?? '')
|
|
220
|
+
/* The ask must name the chat it belongs to: a request that cannot say
|
|
221
|
+
who it is for never gets the mirror. */
|
|
222
|
+
const who = String(body.session ?? '')
|
|
223
|
+
if (who === '') {
|
|
224
|
+
json(400, { ok: false, text: 'which chat asked for it' })
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
await sidecar.ensure({
|
|
228
|
+
url: address === '' ? undefined : address,
|
|
229
|
+
headless: body.headless === undefined ? undefined : body.headless === true,
|
|
230
|
+
})
|
|
231
|
+
/* The mirror belongs to the chat that asked for it; any earlier owner
|
|
232
|
+
loses it and its tab. */
|
|
233
|
+
bridge.claim(who)
|
|
234
|
+
bridge.push(who, { type: 'sidecar-wanted', session: who })
|
|
235
|
+
json(200, { ok: true, ...(await sidecar.state()), owner: bridge.owner })
|
|
1048
236
|
} catch (error) {
|
|
1049
|
-
|
|
1050
|
-
res.end(String(error?.message ?? error))
|
|
1051
|
-
return
|
|
237
|
+
json(500, { ok: false, text: String(error?.message ?? error) })
|
|
1052
238
|
}
|
|
1053
|
-
const delivered = bridge.push(sessionId, frame)
|
|
1054
|
-
res.writeHead(delivered ? 200 : 409, { 'content-type': 'application/json' })
|
|
1055
|
-
res.end(JSON.stringify({ delivered }))
|
|
1056
239
|
return
|
|
1057
240
|
}
|
|
1058
241
|
|
|
1059
|
-
if (path === '/
|
|
1060
|
-
const key = url.searchParams.get('key') ?? ''
|
|
1061
|
-
if (key === '') {
|
|
1062
|
-
res.writeHead(400, { 'content-type': 'text/plain' })
|
|
1063
|
-
res.end('key required')
|
|
1064
|
-
return
|
|
1065
|
-
}
|
|
1066
|
-
if (req.method === 'GET') {
|
|
1067
|
-
const cookies = await readCookies(key)
|
|
1068
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1069
|
-
res.end(JSON.stringify({ cookies }))
|
|
1070
|
-
return
|
|
1071
|
-
}
|
|
242
|
+
if (path === '/sidecar/input' && req.method === 'POST') {
|
|
1072
243
|
let body
|
|
1073
244
|
try {
|
|
1074
245
|
body = JSON.parse(await readBody(req))
|
|
1075
|
-
} catch
|
|
1076
|
-
|
|
1077
|
-
res.end(String(error?.message ?? error))
|
|
246
|
+
} catch {
|
|
247
|
+
json(400, { ok: false, text: 'bad frame' })
|
|
1078
248
|
return
|
|
1079
249
|
}
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
return
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
if (path === '/state' && req.method === 'GET') {
|
|
1088
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1089
|
-
res.end(JSON.stringify({
|
|
1090
|
-
connected: bridge.connected(),
|
|
1091
|
-
pending: bridge.waiters.size,
|
|
1092
|
-
/* Why the last few answers went where they did. */
|
|
1093
|
-
events: bridge.events.slice(-60),
|
|
1094
|
-
asking: bridge.asking === undefined ? null : { chat: bridge.asking.chat, at: new Date(bridge.asking.at).toISOString() },
|
|
1095
|
-
client: clientState(bridge.client),
|
|
1096
|
-
trail: bridge.trail.map((entry) => entry.frame),
|
|
1097
|
-
/* Where a chat's files would land, when its tab told us the folder. */
|
|
1098
|
-
folders: Object.fromEntries(bridge.connected().map((chat) => [chat, bridge.cwdOf(chat) ?? bridge.folderOf(chat) ?? ''])),
|
|
1099
|
-
/* Lets a page that is still running an older bundle replace itself,
|
|
1100
|
-
and tells it which version it should be running. */
|
|
1101
|
-
clientBuild: clientBuild(),
|
|
1102
|
-
version: VERSION,
|
|
1103
|
-
}))
|
|
1104
|
-
return
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
if (path === '/grab' && req.method === 'POST') {
|
|
1108
|
-
const sink = sinks.get(url.searchParams.get('ticket') ?? '')
|
|
1109
|
-
if (sink === undefined) {
|
|
1110
|
-
res.writeHead(404, { 'content-type': 'text/plain' })
|
|
1111
|
-
res.end('unknown transfer')
|
|
250
|
+
/* Input follows the owner: a chat that lost the mirror cannot act on the
|
|
251
|
+
page from a stale tab either. */
|
|
252
|
+
if (body.session !== undefined && String(body.session) !== '' && bridge.owner !== '' && String(body.session) !== bridge.owner) {
|
|
253
|
+
json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
|
|
1112
254
|
return
|
|
1113
255
|
}
|
|
1114
|
-
const offset = Number(url.searchParams.get('offset') ?? '0')
|
|
1115
|
-
if (sink.file === '') sink.file = await freePath(sink.dir, safeName(url.searchParams.get('name')))
|
|
1116
|
-
const stream = createWriteStream(sink.file, { flags: offset > 0 ? 'a' : 'w' })
|
|
1117
256
|
try {
|
|
1118
|
-
await
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
})
|
|
257
|
+
const page = await sidecar.ensure()
|
|
258
|
+
const kind = String(body.kind ?? '')
|
|
259
|
+
const x = Number(body.x ?? 0)
|
|
260
|
+
const y = Number(body.y ?? 0)
|
|
261
|
+
let cursor
|
|
262
|
+
if (kind === 'hover') cursor = await page.hover({ x, y })
|
|
263
|
+
else if (kind === 'move') await page.mouse('mouseMoved', { x, y })
|
|
264
|
+
else if (kind === 'click') {
|
|
265
|
+
const clickCount = body.double === true ? 2 : 1
|
|
266
|
+
await page.mouse('mouseMoved', { x, y })
|
|
267
|
+
await page.mouse('mousePressed', { x, y, clickCount })
|
|
268
|
+
await page.mouse('mouseReleased', { x, y, clickCount })
|
|
269
|
+
if (body.double === true) {
|
|
270
|
+
await page.mouse('mousePressed', { x, y, clickCount: 2 })
|
|
271
|
+
await page.mouse('mouseReleased', { x, y, clickCount: 2 })
|
|
272
|
+
}
|
|
273
|
+
} else if (kind === 'wheel') await page.wheel({ x, y, dx: Number(body.dx ?? 0), dy: Number(body.dy ?? 0) })
|
|
274
|
+
else if (kind === 'text') await page.insertText(String(body.text ?? ''))
|
|
275
|
+
else if (kind === 'key') {
|
|
276
|
+
const name = String(body.key ?? '')
|
|
277
|
+
const known = KEYS[name]
|
|
278
|
+
const spec = {
|
|
279
|
+
key: name,
|
|
280
|
+
code: String(body.code ?? known?.code ?? name),
|
|
281
|
+
keyCode: Number(body.keyCode ?? 0) || known?.keyCode || 0,
|
|
282
|
+
text: body.text === undefined ? known?.text : String(body.text),
|
|
283
|
+
modifiers: Number(body.modifiers ?? 0),
|
|
284
|
+
}
|
|
285
|
+
await page.key('keyDown', spec)
|
|
286
|
+
await page.key('keyUp', { ...spec, text: undefined })
|
|
287
|
+
}
|
|
288
|
+
json(200, cursor === undefined ? { ok: true } : { ok: true, cursor })
|
|
1124
289
|
} catch (error) {
|
|
1125
|
-
|
|
1126
|
-
res.end(String(error?.message ?? error))
|
|
1127
|
-
return
|
|
290
|
+
json(500, { ok: false, text: String(error?.message ?? error) })
|
|
1128
291
|
}
|
|
1129
|
-
const info = await stat(sink.file)
|
|
1130
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1131
|
-
res.end(JSON.stringify({ ok: true, path: sink.file, bytes: info.size }))
|
|
1132
292
|
return
|
|
1133
293
|
}
|
|
1134
294
|
|
|
1135
|
-
if (path === '/
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
295
|
+
if (path === '/sidecar/nav' && req.method === 'POST') {
|
|
296
|
+
let body
|
|
297
|
+
try {
|
|
298
|
+
body = JSON.parse(await readBody(req))
|
|
299
|
+
} catch {
|
|
300
|
+
json(400, { ok: false, text: 'bad frame' })
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
/* Navigation follows the owner too: a lost tab cannot steer the page. */
|
|
304
|
+
if (body.session !== undefined && String(body.session) !== '' && bridge.owner !== '' && String(body.session) !== bridge.owner) {
|
|
305
|
+
json(403, { ok: false, text: 'this chat does not own the browser', owner: bridge.owner })
|
|
1142
306
|
return
|
|
1143
307
|
}
|
|
1144
|
-
let how = 'no-electron'
|
|
1145
|
-
let why = ''
|
|
1146
308
|
try {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
const api = electron?.BrowserWindow === undefined ? electron?.default : electron
|
|
1157
|
-
const windows = api?.BrowserWindow?.getAllWindows?.() ?? []
|
|
1158
|
-
why = `${api?.BrowserWindow === undefined ? 'no-BrowserWindow' : 'windows:' + windows.length} probe:${typeof electron}:${typeof api}:${typeof api?.default}`
|
|
1159
|
-
const win = windows.find((each) => each?.webContents !== undefined && each.webContents.isDestroyed?.() !== true)
|
|
1160
|
-
if (win !== undefined) {
|
|
1161
|
-
how = 'window'
|
|
1162
|
-
/* From the disk, not from the copy the last load left behind. */
|
|
1163
|
-
setTimeout(() => {
|
|
1164
|
-
try {
|
|
1165
|
-
if (typeof win.webContents.reloadIgnoringCache === 'function') win.webContents.reloadIgnoringCache()
|
|
1166
|
-
else win.webContents.reload()
|
|
1167
|
-
} catch {
|
|
1168
|
-
win.webContents.reload()
|
|
1169
|
-
}
|
|
1170
|
-
}, 50).unref?.()
|
|
309
|
+
const page = await sidecar.ensure()
|
|
310
|
+
const op = String(body.op ?? 'goto')
|
|
311
|
+
if (op === 'reload') json(200, { ok: true, ...(await page.reload()) })
|
|
312
|
+
else if (op === 'back') json(200, { ok: true, ...(await page.history(-1)) })
|
|
313
|
+
else if (op === 'forward') json(200, { ok: true, ...(await page.history(1)) })
|
|
314
|
+
else {
|
|
315
|
+
const address = String(body.url ?? '')
|
|
316
|
+
const withScheme = /^[a-z]+:/i.test(address) ? address : `https://${address}`
|
|
317
|
+
json(200, { ok: true, ...(await page.navigate(withScheme)) })
|
|
1171
318
|
}
|
|
1172
319
|
} catch (error) {
|
|
1173
|
-
|
|
1174
|
-
why = String(error?.message ?? error).slice(0, 120)
|
|
320
|
+
json(500, { ok: false, text: String(error?.message ?? error) })
|
|
1175
321
|
}
|
|
1176
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1177
|
-
res.end(JSON.stringify({ ok: how !== 'no-electron', how, why, electron: process.versions.electron ?? null }))
|
|
1178
322
|
return
|
|
1179
323
|
}
|
|
1180
324
|
|
|
1181
|
-
if (path === '/
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
if (req.headers['x-dshib-land'] !== '1') {
|
|
1185
|
-
res.writeHead(403, { 'content-type': 'text/plain' })
|
|
1186
|
-
res.end('forbidden')
|
|
1187
|
-
return
|
|
1188
|
-
}
|
|
1189
|
-
const chat = url.searchParams.get('session') ?? ''
|
|
1190
|
-
const id = url.searchParams.get('id') ?? ''
|
|
1191
|
-
const offset = Number(url.searchParams.get('offset') ?? '0')
|
|
1192
|
-
let landing = landings.get(id)
|
|
1193
|
-
if (landing === undefined) {
|
|
1194
|
-
const base = workBase(bridge, chat)
|
|
1195
|
-
const dir = await bridge.room(base, DOWNLOAD_DIR)
|
|
1196
|
-
landing = { dir, file: await freePath(dir, safeName(url.searchParams.get('name'))) }
|
|
1197
|
-
landings.set(id, landing)
|
|
1198
|
-
if (landings.size > 32) {
|
|
1199
|
-
const oldest = landings.keys().next()
|
|
1200
|
-
if (oldest.done !== true) landings.delete(oldest.value)
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
const stream = createWriteStream(landing.file, { flags: offset > 0 ? 'a' : 'w' })
|
|
1204
|
-
try {
|
|
1205
|
-
await new Promise((settle, fail) => {
|
|
1206
|
-
stream.on('finish', settle)
|
|
1207
|
-
stream.on('error', fail)
|
|
1208
|
-
req.on('error', fail)
|
|
1209
|
-
req.pipe(stream)
|
|
1210
|
-
})
|
|
1211
|
-
} catch (error) {
|
|
1212
|
-
res.writeHead(500, { 'content-type': 'text/plain' })
|
|
1213
|
-
res.end(String(error?.message ?? error))
|
|
1214
|
-
return
|
|
1215
|
-
}
|
|
1216
|
-
const info = await stat(landing.file)
|
|
1217
|
-
if (url.searchParams.get('final') === '1') landings.delete(id)
|
|
1218
|
-
res.writeHead(200, { 'content-type': 'application/json' })
|
|
1219
|
-
res.end(JSON.stringify({ ok: true, path: landing.file, bytes: info.size }))
|
|
325
|
+
if (path === '/sidecar/stop' && req.method === 'POST') {
|
|
326
|
+
await sidecar.shutdown()
|
|
327
|
+
json(200, { ok: true })
|
|
1220
328
|
return
|
|
1221
329
|
}
|
|
1222
330
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const stamp = statSync(clientFile).mtimeMs
|
|
1243
|
-
if (stamp === buildStamp && buildMark !== '') return buildMark
|
|
1244
|
-
buildMark = createHash('sha256').update(readFileSync(clientFile)).digest('hex').slice(0, 16)
|
|
1245
|
-
buildStamp = stamp
|
|
1246
|
-
} catch {
|
|
1247
|
-
buildMark = buildMark === '' ? 'unknown' : buildMark
|
|
331
|
+
if (path === '/downlink' && req.method === 'GET') {
|
|
332
|
+
res.writeHead(200, {
|
|
333
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
334
|
+
'cache-control': 'no-cache, no-transform',
|
|
335
|
+
connection: 'keep-alive',
|
|
336
|
+
'x-accel-buffering': 'no',
|
|
337
|
+
})
|
|
338
|
+
res.write('retry: 2000\n\n')
|
|
339
|
+
bridge.attach(sessionId, res)
|
|
340
|
+
res.write(`data: ${JSON.stringify({ type: 'hello', session: sessionId, at: Date.now() })}\n\n`)
|
|
341
|
+
const beat = setInterval(() => res.write(': beat\n\n'), 15_000)
|
|
342
|
+
beat.unref?.()
|
|
343
|
+
const close = () => {
|
|
344
|
+
clearInterval(beat)
|
|
345
|
+
bridge.detach(sessionId, res)
|
|
346
|
+
}
|
|
347
|
+
req.on('close', close)
|
|
348
|
+
res.on('error', close)
|
|
349
|
+
return
|
|
1248
350
|
}
|
|
1249
|
-
|
|
351
|
+
|
|
352
|
+
json(404, { ok: false, text: 'no such route' })
|
|
1250
353
|
}
|
|
1251
354
|
|
|
1252
|
-
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }), 'dsh-inner-browser-use:
|
|
355
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: ROUTE, handler }), 'dsh-inner-browser-use: mirror route')
|
|
1253
356
|
|
|
1254
357
|
ctx.inject(['tools'], (scope) => {
|
|
1255
|
-
const disposers =
|
|
358
|
+
const disposers = sidecarTools(sidecar, sidecarRefs, {
|
|
359
|
+
/* The mirror changes hands here: the calling chat owns it from now on,
|
|
360
|
+
and every earlier owner loses it. */
|
|
361
|
+
want: (sessionId) => {
|
|
362
|
+
bridge.claim(sessionId)
|
|
363
|
+
bridge.push(sessionId, { type: 'sidecar-wanted', session: sessionId })
|
|
364
|
+
},
|
|
365
|
+
}).map((definition) => scope.tools.register(definition))
|
|
1256
366
|
scope.effect(() => () => {
|
|
1257
367
|
for (const dispose of disposers) dispose()
|
|
1258
368
|
}, 'dsh-inner-browser-use: tool surface')
|
|
@@ -1264,14 +374,14 @@ export function apply(ctx, config = {}) {
|
|
|
1264
374
|
name: 'browser-in-this-chat',
|
|
1265
375
|
order: 858,
|
|
1266
376
|
text: [
|
|
1267
|
-
'##
|
|
377
|
+
'## 网页任务:用 sidecar 浏览器做,不问直接开始',
|
|
1268
378
|
'',
|
|
1269
|
-
'人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成
|
|
1270
|
-
'
|
|
1271
|
-
'
|
|
379
|
+
'人说「用网页端 / 在浏览器里 / 打开某个网站 / 帮我上 ChatGPT 生成 …」时,直接用 `sidecar_*` 工具;不要问要不要打开,也不要另起一个外部 Chrome。它的画面会出现在本会话右栏,做完保留,不主动关。',
|
|
380
|
+
'先接着用已有的页:`sidecar_view`(拿编号控件与可见文字)→ `sidecar_click` / `sidecar_type` → 再 `sidecar_view` 直到出现要的结果;页面要等就重复读,不 sleep。`sidecar_open` 只用于没有页、要换新地址、或要明确刷新;同一个地址不要反复重开。',
|
|
381
|
+
'要保存页面样子用 `sidecar_shot`,它会给出 PNG 绝对路径;要交给人看的文件,把绝对路径写进回复。',
|
|
1272
382
|
].join('\n'),
|
|
1273
383
|
}), 'dsh-inner-browser-use: system prompt section')
|
|
1274
384
|
}
|
|
1275
385
|
|
|
1276
|
-
ctx.logger?.info?.('dsh-inner-browser-use:
|
|
386
|
+
ctx.logger?.info?.('dsh-inner-browser-use: mirror on %s', ROUTE)
|
|
1277
387
|
}
|