@angelky7/dsh-browser-control 0.0.0-stage → 1.1.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.
@@ -0,0 +1,678 @@
1
+ /**
2
+ * Local WebSocket+HTTP bridge between one browser extension and dsh. The
3
+ * extension connects OUT to `ws://127.0.0.1:<port>/ws?token=...`, so no
4
+ * native-messaging host or registry setup exists; dsh drives commands through
5
+ * {@link BridgeServer.execute} and reads link state through {@link BridgeServer.status}.
6
+ * A small HTTP face (`/api/status`, `/api/command`, `/api/cleanup`, `/`) keeps
7
+ * the bridge usable from curl while the server runs. `/api/command` and
8
+ * `/api/cleanup` demand the same token as the WebSocket upgrade; `/` and
9
+ * `/api/status` stay readable because the status page needs them and they only
10
+ * report link state. No response carries `Access-Control-Allow-Origin`, and the
11
+ * upgrade checks `Host` plus `Origin` so neither a web page nor a rebound
12
+ * hostname can attach.
13
+ *
14
+ * Wire protocol (text frames, one JSON object each):
15
+ * - extension → server: `{type:'hello',…}`, `{type:'pong',t}`, `{type:'result',id,ok,result?|error}`
16
+ * - server → extension: `{type:'ping',t}`, `{type:'command',id,command,params}`
17
+ *
18
+ * Command names (sent in `command`) include: `ping`, `browser.info`, `tabs.list/open/close/activate`,
19
+ * `nav`, `eval`, `content`, `find`, `click`, `input`, `press`, `scroll`, `snapshot`, `screenshot`,
20
+ * `wait`, `dialog`, `console.log`, `network.log`, `network.clear`, `pdf`, `emulate`.
21
+ * @module @deepseek-ai/dsh-browser-bridge/server
22
+ */
23
+ import http from 'node:http';
24
+ import { randomUUID, createHash, timingSafeEqual } from 'node:crypto';
25
+ import { mkdir, readdir, rm } from 'node:fs/promises';
26
+ import path from 'node:path';
27
+ import { encodeCloseFrame, encodeControlFrame, encodeTextFrame, FrameReader } from "./ws.js";
28
+ import { BridgeError, classify } from "./errors.js";
29
+ const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
30
+ /**
31
+ * Wire protocol revision this bridge speaks. Kept separate from the package
32
+ * version: an extension that is one patch newer still works, an extension that
33
+ * speaks a different frame contract does not, and only the second case should
34
+ * light up.
35
+ */
36
+ export const BRIDGE_PROTOCOL_VERSION = 1;
37
+ /** Per-command ceiling when the caller passes none. */
38
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
39
+ /** Hard ceiling accepted from callers; longer waits cannot be expressed. */
40
+ export const MAX_COMMAND_TIMEOUT_MS = 300_000;
41
+ const MAX_HTTP_BODY_BYTES = 2 * 1024 * 1024;
42
+ /** The published default. It ships in README and `cordis.patch.yml`, so it is public knowledge. */
43
+ const DEFAULT_TOKEN = 'dsh-local';
44
+ /** Tokens shorter than this cannot be brute-forced meaningfully; the bridge warns at start. */
45
+ const MIN_RECOMMENDED_TOKEN_LENGTH = 16;
46
+ /** Agent scratch-file convention: `__name.mjs`-style temp artifacts at the top level only. */
47
+ const SCRATCH_FILE_PATTERN = /^__[^/\\]+\.(mjs|cjs|js|png|jpg|jpeg)$/i;
48
+ /**
49
+ * Clear the screenshots directory and delete agent scratch files. Only the
50
+ * top level of each location is touched; nested trees stay untouched.
51
+ * @param options - `shotsDir` is cleared of direct file children; `scratchDir`
52
+ * defaults to the process working directory and loses only scratch-named files.
53
+ * @returns counts and names of what was removed.
54
+ */
55
+ export async function cleanupArtifacts(options) {
56
+ await mkdir(options.shotsDir, { recursive: true });
57
+ const shotsEntries = await readdir(options.shotsDir, { withFileTypes: true });
58
+ let shotsRemoved = 0;
59
+ for (const entry of shotsEntries) {
60
+ if (!entry.isFile())
61
+ continue;
62
+ await rm(path.join(options.shotsDir, entry.name));
63
+ shotsRemoved += 1;
64
+ }
65
+ const scratchDir = options.scratchDir ?? process.cwd();
66
+ const scratchEntries = await readdir(scratchDir, { withFileTypes: true }).catch(() => []);
67
+ const scratchRemoved = [];
68
+ for (const entry of scratchEntries) {
69
+ if (!entry.isFile() || !SCRATCH_FILE_PATTERN.test(entry.name))
70
+ continue;
71
+ await rm(path.join(scratchDir, entry.name));
72
+ scratchRemoved.push(entry.name);
73
+ }
74
+ return { shotsRemoved, scratchRemoved };
75
+ }
76
+ /**
77
+ * One live bridge endpoint. Start/stop may cycle repeatedly on one instance;
78
+ * a fresh listener is built per start so a changed config reuses the class
79
+ * without re-allocation concerns. Exactly one extension link is held at a
80
+ * time; a newer WebSocket replaces the older one.
81
+ */
82
+ export class BridgeServer {
83
+ options;
84
+ httpServer;
85
+ clientSocket;
86
+ hello;
87
+ connectedAt;
88
+ pending = new Map();
89
+ lastError;
90
+ continuationRemainder = '';
91
+ continuationOpen = false;
92
+ constructor(options) {
93
+ this.options = options;
94
+ }
95
+ /** Current link state, also served verbatim as `/api/status`. */
96
+ get status() {
97
+ return {
98
+ listening: this.httpServer !== undefined,
99
+ port: this.httpServer !== undefined ? this.options.port : undefined,
100
+ extensionConnected: this.clientSocket !== undefined,
101
+ connectedAt: this.connectedAt?.toISOString(),
102
+ hello: this.hello,
103
+ bridgeProtocol: BRIDGE_PROTOCOL_VERSION,
104
+ extensionProtocol: this.hello?.protocol ?? 0,
105
+ protocolCompatible: this.protocolNote === null,
106
+ protocolNote: this.protocolNote,
107
+ pendingCommands: this.pending.size,
108
+ lastError: this.lastError,
109
+ insecureToken: this.insecureToken,
110
+ };
111
+ }
112
+ get insecureToken() {
113
+ return this.options.token === DEFAULT_TOKEN || this.options.token.trim().length < MIN_RECOMMENDED_TOKEN_LENGTH;
114
+ }
115
+ /** Bind `127.0.0.1:<port>` and start accepting the extension plus HTTP calls. */
116
+ async start() {
117
+ if (this.httpServer !== undefined)
118
+ return;
119
+ const server = http.createServer((req, res) => {
120
+ try {
121
+ this.handleHttp(req, res);
122
+ }
123
+ catch (error) {
124
+ this.log(`http handler failed: ${errorMessage(error)}`);
125
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
126
+ res.end(JSON.stringify({ ok: false, error: 'internal error' }));
127
+ }
128
+ });
129
+ server.on('upgrade', (req, socket) => {
130
+ try {
131
+ this.handleUpgrade(req, socket);
132
+ }
133
+ catch (error) {
134
+ this.log(`upgrade failed: ${errorMessage(error)}`);
135
+ socket.destroy();
136
+ }
137
+ });
138
+ await new Promise((resolve, reject) => {
139
+ const onError = (error) => {
140
+ server.off('listening', onListening);
141
+ this.lastError = errorMessage(error);
142
+ reject(error);
143
+ };
144
+ const onListening = () => {
145
+ server.off('error', onError);
146
+ resolve();
147
+ };
148
+ server.once('error', onError);
149
+ server.once('listening', onListening);
150
+ server.listen(this.options.port, '127.0.0.1');
151
+ });
152
+ this.httpServer = server;
153
+ this.log(`listening on ws://127.0.0.1:${this.options.port}/ws`);
154
+ if (this.insecureToken) {
155
+ // Loud, but not a hard refusal: the published default keeps a fresh install working,
156
+ // and refusing here would break every existing extension pairing.
157
+ this.log(`insecure token (${this.options.token === DEFAULT_TOKEN ? 'the published default' : 'shorter than ' + MIN_RECOMMENDED_TOKEN_LENGTH + ' chars'}) — any local process can present it; set a longer \`token\` in the browser-bridge config`);
158
+ }
159
+ }
160
+ /** Close the extension link, fail every pending command, and release the port. Idempotent. */
161
+ async stop() {
162
+ const server = this.httpServer;
163
+ if (server === undefined)
164
+ return;
165
+ this.httpServer = undefined;
166
+ this.closeClientSocket(1001, 'server stopping');
167
+ this.failAllPending(new Error('browser-bridge stopped'));
168
+ await new Promise((resolve) => {
169
+ server.close(() => resolve());
170
+ });
171
+ this.log('stopped');
172
+ }
173
+ /**
174
+ * Send one command to the connected extension and await its result.
175
+ * Throws while no extension is linked; the rejection message names the fix.
176
+ */
177
+ async execute(command, params, options = {}) {
178
+ const socket = this.clientSocket;
179
+ if (socket === undefined) {
180
+ throw classify(command, new Error('no browser extension connected — open the browser that has the DSH Browser Control extension installed'));
181
+ }
182
+ const timeoutMs = Math.min(MAX_COMMAND_TIMEOUT_MS, Math.max(1, options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS));
183
+ const signal = options.signal;
184
+ if (signal?.aborted)
185
+ throw new Error(`browser command cancelled before send: ${command}`);
186
+ const id = randomUUID();
187
+ const entry = {
188
+ command,
189
+ resolve: () => { },
190
+ reject: () => { },
191
+ timer: setTimeout(() => {
192
+ if (!this.pending.delete(id))
193
+ return;
194
+ entry.reject(new Error(`browser command timed out after ${timeoutMs}ms: ${command}`));
195
+ }, timeoutMs),
196
+ };
197
+ const result = new Promise((resolve, reject) => {
198
+ entry.resolve = resolve;
199
+ entry.reject = reject;
200
+ });
201
+ if (signal !== undefined) {
202
+ entry.onAbort = () => {
203
+ if (!this.pending.delete(id))
204
+ return;
205
+ entry.reject(new Error(`browser command cancelled: ${command}`));
206
+ };
207
+ signal.addEventListener('abort', entry.onAbort, { once: true });
208
+ }
209
+ this.pending.set(id, entry);
210
+ this.log(`-> ${command} (${id})`);
211
+ socket.write(encodeTextFrame(JSON.stringify({ type: 'command', id, command, params })));
212
+ try {
213
+ return await result;
214
+ }
215
+ catch (error) {
216
+ // One choke point: everything above this line that can fail is a link or
217
+ // extension failure, and the caller needs to know whether the page may
218
+ // already have been changed. See src/errors.ts.
219
+ throw classify(command, error);
220
+ }
221
+ finally {
222
+ clearTimeout(entry.timer);
223
+ if (entry.onAbort !== undefined && signal !== undefined)
224
+ signal.removeEventListener('abort', entry.onAbort);
225
+ }
226
+ }
227
+ /**
228
+ * Delete generated screenshots and agent scratch files via
229
+ * {@link cleanupArtifacts} using this server's configured directories.
230
+ */
231
+ async cleanup() {
232
+ const result = await cleanupArtifacts({
233
+ shotsDir: this.options.shotsDir,
234
+ ...(this.options.scratchDir === undefined ? {} : { scratchDir: this.options.scratchDir }),
235
+ });
236
+ this.log(`cleanup: ${result.shotsRemoved} screenshot(s), ${result.scratchRemoved.length} scratch file(s)`);
237
+ return result;
238
+ }
239
+ log(line) {
240
+ this.options.log?.(line);
241
+ }
242
+ /**
243
+ * Mismatch description, or null when the two sides can talk. An older
244
+ * extension that declares nothing is treated as v1-compatible: it still
245
+ * answers every command this bridge has ever sent, so failing the link would
246
+ * break working installs for a cosmetic gap.
247
+ */
248
+ get protocolNote() {
249
+ if (this.hello === undefined)
250
+ return null;
251
+ const theirs = this.hello.protocol;
252
+ if (theirs === 0)
253
+ return null;
254
+ if (theirs > BRIDGE_PROTOCOL_VERSION) {
255
+ return `扩展声明协议 v${theirs},本桥只支持到 v${BRIDGE_PROTOCOL_VERSION} —— 请升级 dsh 插件,否则新命令会报「未知命令」`;
256
+ }
257
+ if (theirs < BRIDGE_PROTOCOL_VERSION) {
258
+ return `扩展协议 v${theirs} 低于本桥 v${BRIDGE_PROTOCOL_VERSION} —— 旧扩展可能缺少新命令,必要时重新下载扩展包`;
259
+ }
260
+ return null;
261
+ }
262
+ /* ------------------------------------------------------------- HTTP face */
263
+ /**
264
+ * Whether a request carries the bridge token, the same credential the WebSocket
265
+ * upgrade checks. Accepted forms: `Authorization: Bearer <token>`, `X-Token: <token>`
266
+ * and `?token=<token>` (the query form exists for curl and for the status page).
267
+ */
268
+ authorized(req, url) {
269
+ const authorization = req.headers.authorization;
270
+ const header = typeof authorization === 'string' && /^bearer\s+/i.test(authorization)
271
+ ? authorization.replace(/^bearer\s+/i, '')
272
+ : typeof req.headers['x-token'] === 'string'
273
+ ? req.headers['x-token']
274
+ : '';
275
+ const presented = header.length > 0 ? header : (url.searchParams.get('token') ?? '');
276
+ return tokenMatches(presented, this.options.token);
277
+ }
278
+ rejectUnauthenticated(res, route) {
279
+ this.log(`http ${route}: rejected (missing or wrong token)`);
280
+ this.respondJson(res, 401, {
281
+ ok: false,
282
+ error: 'unauthorized: pass the bridge token as "Authorization: Bearer <token>", "X-Token: <token>" or ?token=<token>',
283
+ });
284
+ }
285
+ handleHttp(req, res) {
286
+ const url = new URL(req.url ?? '/', 'http://localhost');
287
+ if (req.method === 'OPTIONS') {
288
+ // No cross-origin access is granted any more: the status page is same-origin and
289
+ // the extension only ever talks over the WebSocket. Answering a preflight without
290
+ // `Access-Control-Allow-Origin` is what makes the browser block the real request.
291
+ res.writeHead(204);
292
+ res.end();
293
+ return;
294
+ }
295
+ if (url.pathname === '/api/status' && req.method === 'GET') {
296
+ this.respondJson(res, 200, { ok: true, ...this.status, wsUrl: `ws://127.0.0.1:${this.options.port}/ws`, shotsDir: path.resolve(this.options.shotsDir) });
297
+ return;
298
+ }
299
+ if (url.pathname === '/api/cleanup' && req.method === 'POST') {
300
+ // Deletes files, so it authenticates like the command endpoint does.
301
+ if (!this.authorized(req, url)) {
302
+ this.rejectUnauthenticated(res, 'POST /api/cleanup');
303
+ return;
304
+ }
305
+ void this.cleanup().then((result) => this.respondJson(res, 200, { ok: true, ...result }), (error) => this.respondJson(res, 500, { ok: false, error: errorMessage(error) }));
306
+ return;
307
+ }
308
+ if (url.pathname === '/api/command' && req.method === 'POST') {
309
+ // Reaches the extension command table (eval / click / input / screenshot), so it
310
+ // used to be the whole attack surface; 127.0.0.1 alone never was a boundary.
311
+ if (!this.authorized(req, url)) {
312
+ this.rejectUnauthenticated(res, 'POST /api/command');
313
+ return;
314
+ }
315
+ this.handleCommandRequest(req, res);
316
+ return;
317
+ }
318
+ if (url.pathname === '/' && req.method === 'GET') {
319
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
320
+ res.end(statusPageHtml(this.options.port));
321
+ return;
322
+ }
323
+ this.respondJson(res, 404, { ok: false, error: `no route: ${req.method} ${url.pathname}` });
324
+ }
325
+ respondJson(res, statusCode, body) {
326
+ // This is the shared exit for every JSON reply, so it is also where cross-origin
327
+ // access used to leak: no `Access-Control-Allow-Origin` is sent at all now.
328
+ res.writeHead(statusCode, {
329
+ 'Content-Type': 'application/json; charset=utf-8',
330
+ 'Cache-Control': 'no-store',
331
+ });
332
+ res.end(JSON.stringify(body));
333
+ }
334
+ handleCommandRequest(req, res) {
335
+ const chunks = [];
336
+ let size = 0;
337
+ let aborted = false;
338
+ req.on('data', (chunk) => {
339
+ if (aborted)
340
+ return; // keep discarding so the client sees the 413 instead of a reset
341
+ size += chunk.length;
342
+ if (size > MAX_HTTP_BODY_BYTES) {
343
+ aborted = true;
344
+ chunks.length = 0;
345
+ this.respondJson(res, 413, { ok: false, error: 'body too large' });
346
+ req.resume();
347
+ return;
348
+ }
349
+ chunks.push(chunk);
350
+ });
351
+ req.on('end', () => {
352
+ if (aborted)
353
+ return;
354
+ let parsed;
355
+ try {
356
+ parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
357
+ }
358
+ catch (error) {
359
+ this.respondJson(res, 400, { ok: false, error: `invalid JSON body: ${errorMessage(error)}` });
360
+ return;
361
+ }
362
+ if (typeof parsed.command !== 'string' || parsed.command.length === 0) {
363
+ this.respondJson(res, 400, { ok: false, error: 'body must be {"command": "...", "params": {...}}' });
364
+ return;
365
+ }
366
+ const timeoutMs = typeof parsed.timeoutMs === 'number' ? parsed.timeoutMs : undefined;
367
+ this.execute(parsed.command, (parsed.params ?? {}), timeoutMs === undefined ? {} : { timeoutMs }).then((result) => this.respondJson(res, 200, { ok: true, result }), (error) => {
368
+ if (error instanceof BridgeError) {
369
+ // 503 = 现在根本没法工作;504 = 超时且结果未知;502 = 扩展报了具体失败
370
+ const status = error.code === 'extension_offline' || error.code === 'bridge_disabled' || error.code === 'not_configured'
371
+ ? 503
372
+ : error.code === 'command_timeout' || error.code === 'link_lost'
373
+ ? 504
374
+ : 502;
375
+ this.respondJson(res, status, error.toJSON());
376
+ return;
377
+ }
378
+ this.respondJson(res, 502, { ok: false, error: errorMessage(error) });
379
+ });
380
+ });
381
+ }
382
+ /* ------------------------------------------------------- websocket link */
383
+ handleUpgrade(req, socket) {
384
+ const url = new URL(req.url ?? '/', 'http://localhost');
385
+ if (url.pathname !== '/ws') {
386
+ socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
387
+ socket.destroy();
388
+ return;
389
+ }
390
+ if (!tokenMatches(url.searchParams.get('token') ?? '', this.options.token)) {
391
+ this.log('websocket rejected: bad token');
392
+ socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
393
+ socket.destroy();
394
+ return;
395
+ }
396
+ // A WebSocket handshake is not CORS-guarded, so a page could dial 127.0.0.1 freely if
397
+ // it had a token to present. Origin and Host pin it down: no web-scheme caller, and no
398
+ // hostname that resolves back to the loopback via rebinding.
399
+ if (!isLoopbackHost(String(req.headers.host ?? ''))) {
400
+ this.log(`websocket rejected: host ${req.headers.host}`);
401
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
402
+ socket.destroy();
403
+ return;
404
+ }
405
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : '';
406
+ if (!isTrustedUpgradeOrigin(origin)) {
407
+ this.log(`websocket rejected: origin ${origin}`);
408
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
409
+ socket.destroy();
410
+ return;
411
+ }
412
+ const key = req.headers['sec-websocket-key'];
413
+ if (typeof key !== 'string' || String(req.headers.upgrade ?? '').toLowerCase() !== 'websocket') {
414
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
415
+ socket.destroy();
416
+ return;
417
+ }
418
+ const accept = createHash('sha1').update(key + WS_GUID).digest('base64');
419
+ socket.write('HTTP/1.1 101 Switching Protocols\r\n'
420
+ + 'Upgrade: websocket\r\n'
421
+ + 'Connection: Upgrade\r\n'
422
+ + `Sec-WebSocket-Accept: ${accept}\r\n`
423
+ + '\r\n');
424
+ this.closeClientSocket(1000, 'replaced by a newer connection');
425
+ this.clientSocket = socket;
426
+ this.hello = undefined;
427
+ this.connectedAt = new Date();
428
+ this.continuationRemainder = '';
429
+ this.continuationOpen = false;
430
+ this.log(`extension connected from ${socket.remoteAddress}:${socket.remotePort}`);
431
+ const reader = new FrameReader();
432
+ socket.on('data', (chunk) => {
433
+ reader.push(chunk);
434
+ try {
435
+ reader.drain((frame) => this.handleFrame(frame));
436
+ }
437
+ catch (error) {
438
+ this.log(`protocol error: ${errorMessage(error)}`);
439
+ this.lastError = errorMessage(error);
440
+ this.closeClientSocket(1002, 'protocol error');
441
+ }
442
+ });
443
+ socket.on('error', (error) => {
444
+ this.logVerbose(`socket error: ${errorMessage(error)}`);
445
+ this.closeClientSocket(1011, 'server error');
446
+ });
447
+ socket.on('close', () => {
448
+ if (this.clientSocket !== socket)
449
+ return;
450
+ this.closeClientSocket(1000, '');
451
+ });
452
+ }
453
+ handleFrame(frame) {
454
+ switch (frame.opcode) {
455
+ case 0x1:
456
+ case 0x0:
457
+ this.handleText(frame.payload.toString('utf8'), frame.fin, frame.opcode);
458
+ return;
459
+ case 0x8:
460
+ this.closeClientSocket(1000, '');
461
+ return;
462
+ case 0x9:
463
+ this.clientSocket?.write(encodeControlFrame(0xA, frame.payload));
464
+ return;
465
+ default:
466
+ return; // pong or binary: nothing to do
467
+ }
468
+ }
469
+ handleText(text, fin, opcode) {
470
+ if (opcode === 0x0 && !this.continuationOpen)
471
+ return; // stray continuation
472
+ if (opcode === 0x1)
473
+ this.continuationRemainder = '';
474
+ this.continuationOpen = opcode === 0x0 || !fin;
475
+ this.continuationRemainder += text;
476
+ if (!fin)
477
+ return;
478
+ const full = this.continuationRemainder;
479
+ this.continuationRemainder = '';
480
+ this.continuationOpen = false;
481
+ let msg;
482
+ try {
483
+ msg = JSON.parse(full);
484
+ }
485
+ catch {
486
+ this.logVerbose('dropped a non-JSON extension message');
487
+ return;
488
+ }
489
+ if (msg.type === 'hello') {
490
+ const declared = typeof msg.protocol === 'number' ? msg.protocol : 0;
491
+ this.hello = {
492
+ client: typeof msg.client === 'string' ? msg.client : 'unknown',
493
+ version: typeof msg.version === 'string' ? msg.version : 'unknown',
494
+ protocol: declared,
495
+ browser: (msg.browser ?? { name: 'unknown', version: 'unknown', ua: '' }),
496
+ };
497
+ this.log(`hello: client=${this.hello.client} v${this.hello.version} protocol=v${declared || '?'} browser=${this.hello.browser.name} ${this.hello.browser.version}`);
498
+ const note = this.protocolNote;
499
+ if (note !== null)
500
+ this.log(`protocol mismatch: ${note}`);
501
+ return;
502
+ }
503
+ if (msg.type === 'pong')
504
+ return;
505
+ if (msg.type === 'result' && typeof msg.id === 'string') {
506
+ const entry = this.pending.get(msg.id);
507
+ if (entry === undefined) {
508
+ this.logVerbose(`result for unknown id ${msg.id}`);
509
+ return;
510
+ }
511
+ this.pending.delete(msg.id);
512
+ clearTimeout(entry.timer);
513
+ if (msg.ok === true)
514
+ entry.resolve(msg.result);
515
+ else
516
+ entry.reject(new Error(typeof msg.error === 'string' && msg.error.length > 0 ? msg.error : 'unknown extension error'));
517
+ }
518
+ }
519
+ closeClientSocket(code, reason) {
520
+ const socket = this.clientSocket;
521
+ if (socket === undefined)
522
+ return;
523
+ this.clientSocket = undefined;
524
+ this.hello = undefined;
525
+ this.connectedAt = undefined;
526
+ this.failAllPending(new Error('the browser extension disconnected mid-command'));
527
+ try {
528
+ socket.write(encodeCloseFrame(code, reason));
529
+ socket.end();
530
+ setTimeout(() => socket.destroy(), 500).unref();
531
+ }
532
+ catch {
533
+ socket.destroy();
534
+ }
535
+ if (reason.length > 0)
536
+ this.log(`extension disconnected (${reason})`);
537
+ }
538
+ failAllPending(error) {
539
+ for (const [, entry] of this.pending) {
540
+ clearTimeout(entry.timer);
541
+ entry.reject(error);
542
+ }
543
+ this.pending.clear();
544
+ }
545
+ logVerbose(line) {
546
+ // Verbose chatter stays available under the same line logger; lifecycle logs matter most.
547
+ this.options.log?.(line);
548
+ }
549
+ }
550
+ function errorMessage(error) {
551
+ return error instanceof Error ? error.message : String(error);
552
+ }
553
+ /** Constant-time token comparison; a length mismatch is the only thing leaked. */
554
+ function tokenMatches(presented, expected) {
555
+ const a = Buffer.from(presented, 'utf8');
556
+ const b = Buffer.from(expected, 'utf8');
557
+ if (a.length !== b.length)
558
+ return false;
559
+ return timingSafeEqual(a, b);
560
+ }
561
+ /**
562
+ * `Host` must name the loopback address this server binds. Rejecting anything else is
563
+ * what stops a rebinding setup (a public hostname resolving to 127.0.0.1) from reaching
564
+ * the bridge through the browser.
565
+ */
566
+ function isLoopbackHost(host) {
567
+ const withoutPort = host.replace(/:\d+$/, '');
568
+ return withoutPort === '127.0.0.1' || withoutPort === 'localhost' || withoutPort === '[::1]' || withoutPort === '::1';
569
+ }
570
+ /**
571
+ * An `Origin` of a web scheme means a page opened the socket — WebSocket handshakes are
572
+ * not covered by CORS, so that has to be refused here. Extension schemes (`chrome-extension:`
573
+ * etc.) and non-browser clients that send no Origin at all are accepted.
574
+ */
575
+ function isTrustedUpgradeOrigin(origin) {
576
+ if (origin === '')
577
+ return true;
578
+ if (/^(?:chrome|moz|ms|safari)-extension:\/\//.test(origin))
579
+ return true;
580
+ return /^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(origin);
581
+ }
582
+ /** Minimal self-refreshing status page with the cleanup action. */
583
+ function statusPageHtml(port) {
584
+ return `<!doctype html>
585
+ <html lang="zh-CN">
586
+ <head>
587
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
588
+ <title>DSH Browser Control — 状态</title>
589
+ <style>
590
+ :root{--bg:#0f172a;--card:#1e293b;--border:#334155;--text:#e2e8f0;--dim:#94a3b8;
591
+ --green:#22c55e;--green-bg:rgba(34,197,94,.12);--red:#ef4444;--red-bg:rgba(239,68,68,.10)}
592
+ *{box-sizing:border-box;margin:0;padding:0}
593
+ body{font-family:system-ui,"Segoe UI","Microsoft YaHei",sans-serif;background:var(--bg);
594
+ color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
595
+ .card{background:var(--card);border:1px solid var(--border);border-radius:16px;max-width:440px;width:100%;overflow:hidden}
596
+ .head{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px}
597
+ .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0;background:var(--red)}
598
+ .dot.on{background:var(--green);box-shadow:0 0 6px var(--green)}
599
+ .head h1{font-size:15px;font-weight:600}.head small{color:var(--dim);font-size:11px}
600
+ .rows{padding:16px 24px}
601
+ .row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;
602
+ border-bottom:1px solid var(--border);font-size:13px}
603
+ .row:last-child{border-bottom:none}
604
+ .row .k{color:var(--dim)}.row .v{font-weight:500;text-align:right;max-width:260px;word-break:break-all}
605
+ .badge{display:inline-block;padding:2px 8px;border-radius:9999px;font-size:11px;font-weight:600}
606
+ .badge.on{background:var(--green-bg);color:var(--green)}.badge.off{background:var(--red-bg);color:var(--red)}
607
+ .actions{padding:8px 24px;display:flex;gap:8px}
608
+ .btn{padding:7px 14px;border:1px solid var(--border);border-radius:9px;background:var(--card);
609
+ color:var(--text);font-size:12px;cursor:pointer}.btn:hover{background:#334155}
610
+ .btn.danger{border-color:var(--red);color:var(--red)}.btn.danger:hover{background:var(--red-bg)}
611
+ .tok{flex:1;min-width:150px;padding:7px 10px;border:1px solid var(--border);border-radius:9px;
612
+ background:var(--bg);color:var(--text);font-size:12px}
613
+ .hint{color:var(--dim);font-size:11px;padding:4px 24px 8px}
614
+ .pre{background:var(--bg);border-top:1px solid var(--border);padding:12px 24px}
615
+ .pre code{color:var(--dim);font-size:11px;white-space:pre-wrap;word-break:break-all}
616
+ .foot{text-align:center;padding:12px;font-size:11px;color:var(--dim)}
617
+ </style>
618
+ </head>
619
+ <body>
620
+ <div class="card">
621
+ <div class="head">
622
+ <div class="dot" id="dot"></div>
623
+ <div><h1>DSH Browser Control</h1><small id="sub">加载中…</small></div>
624
+ </div>
625
+ <div class="rows" id="rows"></div>
626
+ <div class="actions">
627
+ <input class="tok" id="token" type="password" placeholder="桥接 Token(清理按钮需要)" autocomplete="off">
628
+ <button class="btn" onclick="refresh()">刷新</button>
629
+ <button class="btn danger" id="cleanup">🧹 清理截图 + 脚本草稿</button>
630
+ </div>
631
+ <div class="hint">Token 只保存在本次浏览器会话(sessionStorage),不写盘;留空则点按钮时询问。/api/command 与 /api/cleanup 现在都要求带 Token。</div>
632
+ <div class="pre"><code id="raw"> </code></div>
633
+ <div class="foot">DSH Browser Bridge · 127.0.0.1:${port}</div>
634
+ </div>
635
+ <script>
636
+ async function refresh(){
637
+ try{
638
+ const r=await fetch('/api/status');const j=await r.json();
639
+ const connected=j.extensionConnected;
640
+ document.getElementById('dot').className='dot'+(connected?' on':'');
641
+ document.getElementById('sub').textContent=connected
642
+ ?(j.hello?j.hello.client+' '+j.hello.browser.name+' '+j.hello.browser.version:'已连接')
643
+ :'等待浏览器扩展连接…';
644
+ const rows=[
645
+ ['监听端口','<span class="badge on">'+j.port+'</span>'],
646
+ ['扩展状态',connected?'<span class="badge on">已连接</span>':'<span class="badge off">未连接</span>'],
647
+ ['扩展类型',j.hello?(j.hello.client+' v'+j.hello.version):'—'],
648
+ ['浏览器',j.hello?(j.hello.browser.name+' '+j.hello.browser.version):'—'],
649
+ ['等待命令',String(j.pendingCommands??0)],
650
+ ['连接时间',j.connectedAt?new Date(j.connectedAt).toLocaleString():'—'],
651
+ ['截图目录',j.shotsDir||'—'],
652
+ ['令牌强度',j.insecureToken?'<span class="badge off">默认/过短,建议改了</span>':'<span class="badge on">已自定义</span>'],
653
+ ['协议版本','桥 v'+j.bridgeProtocol+' / 扩展 v'+(j.extensionProtocol||'?')+(j.protocolCompatible?'':' <span class="badge off">不匹配</span>')],
654
+ ];
655
+ if(j.protocolNote) rows.push(['协议提示','<span style="color:var(--red)">'+j.protocolNote+'</span>']);
656
+ document.getElementById('rows').innerHTML=rows.map(([k,v])=>'<div class="row"><span class="k">'+k+'</span><span class="v">'+v+'</span></div>').join('');
657
+ document.getElementById('raw').textContent=JSON.stringify(j,null,2);
658
+ }catch(e){document.getElementById('sub').textContent='连接失败: '+e.message}
659
+ }
660
+ function bridgeToken(){
661
+ const el=document.getElementById('token');
662
+ let t=(el.value||'').trim()||sessionStorage.getItem('dshBridgeToken')||'';
663
+ if(!t){ t=(prompt('桥接 Token(在 profile 的 browser-bridge 配置里,token 字段)')||'').trim(); el.value=t; }
664
+ if(t) sessionStorage.setItem('dshBridgeToken',t);
665
+ return t;
666
+ }
667
+ document.getElementById('cleanup').onclick=async()=>{
668
+ if(!confirm('确认清理截图与临时脚本?'))return;
669
+ const r=await fetch('/api/cleanup',{method:'POST',headers:{'Authorization':'Bearer '+bridgeToken()}});
670
+ const j=await r.json();
671
+ if(r.status===401){alert('Token 不对,清理没有执行。');return;}
672
+ alert(j.ok?('已删除 '+j.shotsRemoved+' 个截图,'+j.scratchRemoved.length+' 个草稿'):('失败:'+j.error));
673
+ refresh();
674
+ };
675
+ refresh();setInterval(refresh,2000);
676
+ </script>
677
+ </body></html>`;
678
+ }