dsh-browser-plus 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +131 -144
  2. package/README.en.md +27 -8
  3. package/README.md +27 -8
  4. package/client/index.js +185 -0
  5. package/cordis.patch.yml +24 -0
  6. package/docs/README.md +1 -1
  7. package/docs/SOAK-CHECKLIST.md +1 -1
  8. package/docs/tool-reference.md +5 -3
  9. package/docs/user-guide.md +20 -4
  10. package/lib/browser/runtime.d.ts +14 -1
  11. package/lib/browser/runtime.js +28 -0
  12. package/lib/browser/types.d.ts +96 -6
  13. package/lib/browser-electron/chrome-state.d.ts +24 -0
  14. package/lib/browser-electron/entry.d.ts +12 -5
  15. package/lib/browser-electron/entry.js +5 -2
  16. package/lib/browser-electron/host-main.d.ts +2 -1
  17. package/lib/browser-electron/host-main.js +205 -8
  18. package/lib/browser-electron/page-chrome.js +249 -14
  19. package/lib/browser-electron/provider.d.ts +60 -2
  20. package/lib/browser-electron/provider.js +300 -50
  21. package/lib/browser-electron/remote-host.d.ts +3 -1
  22. package/lib/browser-electron/remote-host.js +42 -1
  23. package/lib/client.js +185 -0
  24. package/lib/command-browser/index.d.ts +20 -0
  25. package/lib/command-browser/index.js +35 -0
  26. package/lib/http-browser/index.d.ts +28 -0
  27. package/lib/http-browser/index.js +110 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/task-todos/index.d.ts +25 -0
  31. package/lib/task-todos/index.js +100 -0
  32. package/lib/tool-browser/index.js +190 -95
  33. package/package.json +27 -2
  34. package/scripts/build-client.mjs +20 -0
  35. package/scripts/smoke-browser-tools.mjs +84 -1
  36. package/scripts/smoke-chrome-world.mjs +6 -4
  37. package/scripts/test-orb-drag.mjs +83 -0
  38. package/src/browser/runtime.ts +36 -0
  39. package/src/browser/types.ts +98 -6
  40. package/src/browser-electron/chrome-state.ts +18 -0
  41. package/src/browser-electron/entry.ts +17 -7
  42. package/src/browser-electron/host-main.ts +207 -11
  43. package/src/browser-electron/page-chrome.ts +249 -14
  44. package/src/browser-electron/provider.ts +328 -50
  45. package/src/browser-electron/remote-host.ts +49 -2
  46. package/src/command-browser/index.ts +61 -0
  47. package/src/http-browser/index.ts +139 -0
  48. package/src/index.ts +13 -0
  49. package/src/task-todos/index.ts +114 -0
  50. package/src/tool-browser/index.ts +193 -96
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 真正驱动指针事件的悬浮球拖动测试(审查要的那条)。
3
+ *
4
+ * 单元测试只能断言「生成的脚本文本里有某个公式」,抓不到 H2 那类错误 ——
5
+ * 慢速拖动曾经因为阈值拿「上一帧已应用的位置」当基准,永远不算拖动:
6
+ * 位置不落盘、松手还会误触 click。这里用离屏 Electron 跑真脚本、派发真指针事件。
7
+ *
8
+ * node scripts/test-orb-drag.mjs
9
+ */
10
+ import { spawnSync } from 'node:child_process'
11
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { createRequire } from 'node:module'
15
+
16
+ const require = createRequire(import.meta.url)
17
+ const chrome = require('../lib/browser-electron/page-chrome.js')
18
+ const dir = mkdtempSync(join(tmpdir(), 'orb-drag-'))
19
+ const W = 620
20
+ const H = 480
21
+ const CENTER = { x: W - 18 - 22, y: H - 18 - 22 }
22
+
23
+ const probe = [
24
+ 'window.__moves = [];',
25
+ 'window.__seen = [];',
26
+ 'window.addEventListener("pointerdown", e => window.__seen.push(["down", e.buttons, Math.round(e.clientX)]), true);',
27
+ 'window.addEventListener("pointermove", e => window.__seen.push(["move", e.buttons, Math.round(e.clientX)]), true);',
28
+ 'window.__dshChromeToken = "t";',
29
+ 'window.__dshChromeTaskKey = "default";',
30
+ 'window.__dshBrowserTaskAction = function (payload) { try { const a = JSON.parse(payload); if (a && a.type === "orb-move") window.__moves.push(a) } catch {} };',
31
+ 'window.__dshChromeBootstrap = ' + JSON.stringify({
32
+ kind: 'bootstrap', epoch: 1, revision: 1, selectedTaskKey: 'default',
33
+ panels: { tasks: false, trail: false },
34
+ tasks: [{ key: 'default', label: '', active: true, tabs: 1, status: 'running', updatedAt: Date.now() }],
35
+ tabs: [], trail: [], bookmarks: [], bookmarkBar: false,
36
+ }) + ';',
37
+ ].join('')
38
+
39
+ const html = '<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;background:#202124}</style></head><body>'
40
+ + '<scr' + 'ipt>' + probe + '</scr' + 'ipt>'
41
+ + '<scr' + 'ipt>' + chrome.buildPageChromeScript('t', 'page') + '</scr' + 'ipt>'
42
+ + '</body></html>'
43
+
44
+ const driver = `
45
+ const { app, BrowserWindow } = require('electron')
46
+ const fs = require('fs')
47
+ const file = process.argv[2]
48
+ const out = process.argv[3]
49
+ const center = JSON.parse(process.argv[4])
50
+ app.whenReady().then(async () => {
51
+ const win = new BrowserWindow({ width: ${W}, height: ${H}, show: false, webPreferences: { offscreen: true } })
52
+ await win.loadFile(file)
53
+ await new Promise(r => setTimeout(r, 700))
54
+ const send = (type, x, y, buttons) => win.webContents.sendInputEvent({ type, x: Math.round(x), y: Math.round(y), button: 'left', buttons, clickCount: 1 })
55
+ send('mouseDown', center.x, center.y, 1)
56
+ await new Promise(r => setTimeout(r, 80))
57
+ // 每步之间等一帧多一点:Chromium 会把挨得太近的 pointermove 合并掉,
58
+ // 合并之后「每帧位移」就超过阈值了,测不出 H2 那种慢速拖动。
59
+ for (let i = 1; i <= 60; i++) { send('mouseMove', center.x - i * 2, center.y - i, 1); await new Promise(r => setTimeout(r, 25)) }
60
+ send('mouseUp', center.x - 120, center.y - 60, 0)
61
+ await new Promise(r => setTimeout(r, 250))
62
+ fs.writeFileSync(out, JSON.stringify({
63
+ moves: await win.webContents.executeJavaScript('window.__moves'),
64
+ seen: await win.webContents.executeJavaScript('window.__seen.slice(0, 3)'),
65
+ total: await win.webContents.executeJavaScript('window.__seen.length'),
66
+ }))
67
+ console.log('DRIVER_DONE')
68
+ app.quit()
69
+ })
70
+ `
71
+ writeFileSync(join(dir, 'page.html'), html)
72
+ writeFileSync(join(dir, 'drive.cjs'), driver)
73
+ const out = join(dir, 'result.json')
74
+ const run = spawnSync(process.execPath, [require.resolve('electron/cli.js'), join(dir, 'drive.cjs'), join(dir, 'page.html'), out, JSON.stringify(CENTER)], { encoding: 'utf8' })
75
+ const text = String(run.stdout ?? '') + String(run.stderr ?? '')
76
+ if (!text.includes('DRIVER_DONE')) { console.error('driver did not finish:\n' + text.slice(-900)); process.exit(1) }
77
+ const result = JSON.parse(readFileSync(out, 'utf8'))
78
+ console.log('page saw ' + result.total + ' pointer events; first 3: ' + JSON.stringify(result.seen))
79
+ const moves = result.moves
80
+ if (moves.length === 0) { console.error('FAIL: a slow drag produced no orb-move — the position would not be persisted'); process.exit(1) }
81
+ const last = moves[moves.length - 1]
82
+ if (!(last.x < CENTER.x) || !(last.y < CENTER.y)) { console.error('FAIL: the orb did not move up-left: ' + JSON.stringify(last)); process.exit(1) }
83
+ console.log('OK: slow drag persisted ' + moves.length + ' move(s), last ' + last.x + ',' + last.y)
@@ -47,9 +47,14 @@ import type {
47
47
  BrowserTypeRequest,
48
48
  BrowserUploadFileRequest,
49
49
  BrowserUploadFileResult,
50
+ BrowserPdfRequest,
51
+ BrowserPdfResult,
52
+ BrowserHighlightRequest,
53
+ BrowserHighlightResult,
50
54
  BrowserWaitForRequest,
51
55
  BrowserWaitForResult,
52
56
  BrowserChallenge,
57
+ BrowserTaskTodo,
53
58
  ExportedCookie,
54
59
  } from './types.ts'
55
60
  import { BrowserError } from './types.ts'
@@ -334,6 +339,14 @@ export class BrowserRuntime extends Service {
334
339
  }
335
340
 
336
341
  /** Wait for an element through the selected provider (bounded polling). */
342
+ async highlight(session: BrowserSessionId, request: BrowserHighlightRequest, signal?: AbortSignal): Promise<BrowserHighlightResult> {
343
+ return this.resolveProvider().highlight(session, request, signal)
344
+ }
345
+
346
+ async pdf(session: BrowserSessionId, request: BrowserPdfRequest, signal?: AbortSignal): Promise<BrowserPdfResult> {
347
+ return this.resolveProvider().pdf(session, request, signal)
348
+ }
349
+
337
350
  async waitForElement(session: BrowserSessionId, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult> {
338
351
  return this.resolveProvider().waitForElement(session, request, signal)
339
352
  }
@@ -448,6 +461,29 @@ export class BrowserRuntime extends Service {
448
461
  return this.resolveProvider().setHandoff(session, state)
449
462
  }
450
463
 
464
+ /**
465
+ * Bring the shared browser window to the front through the selected
466
+ * provider, opening it when nothing is open yet.
467
+ */
468
+ async ensureWindowVisible(): Promise<void> {
469
+ const provider = this.resolveProvider()
470
+ if (provider.ensureWindowVisible === undefined) {
471
+ throw new BrowserError('browser: this provider has no window to open', 'BROWSER_WINDOW_UNSUPPORTED')
472
+ }
473
+ return provider.ensureWindowVisible()
474
+ }
475
+
476
+ /**
477
+ * Mirror one task's Agent todo list into the shared window (the floating orb
478
+ * reads it there). A provider without a window has nowhere to put it, so this
479
+ * is a no-op rather than an error.
480
+ */
481
+ async pushTaskTodos(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void> {
482
+ const provider = this.resolveProvider()
483
+ if (provider.pushTaskTodos === undefined) return
484
+ return provider.pushTaskTodos(taskKey, todos)
485
+ }
486
+
451
487
  /** Close the session through the selected provider. Idempotent; a missing
452
488
  * provider is treated as already-closed so teardown paths stay no-ops. */
453
489
  async close(session: BrowserSessionId): Promise<void> {
@@ -147,26 +147,81 @@ export interface BrowserUploadFileResult {
147
147
  readonly path: string
148
148
  }
149
149
 
150
- /** Wait for an element matching a CSS selector to appear (optionally visible). */
150
+ /** Which state a wait is watching for. */
151
+ export type BrowserWaitForState = 'visible' | 'attached' | 'hidden' | 'detached'
152
+
153
+ /** Wait for a selector, some text, or both to reach a state. */
151
154
  export interface BrowserWaitForRequest {
152
- /** CSS selector to wait for. */
153
- readonly selector: string
155
+ /** CSS selector to wait for. Omit to watch the document's own text (give `text`). */
156
+ readonly selector?: string
157
+ /** Text that must be present: inside the matched element when a selector is given, otherwise anywhere in the document. */
158
+ readonly text?: string
159
+ /**
160
+ * The state to wait for. `visible` (the default) needs a matching element at least 4x4 px and not
161
+ * visibility:hidden / display:none; `attached` only needs it to exist; `hidden` and `detached`
162
+ * wait for the opposite, so they are how you wait for something to go away.
163
+ */
164
+ readonly state?: BrowserWaitForState
154
165
  /** Total budget in ms. Default 15000. */
155
166
  readonly timeoutMs?: number
156
- /** Wait for the element to be visible (at least 4x4 px and not visibility:hidden or display:none). Default true. */
167
+ /** Back-compat for `state: 'attached'` (`visible: false`). Prefer `state`. */
157
168
  readonly visible?: boolean
158
169
  }
159
170
 
160
171
  /** Outcome of a successful wait. */
161
172
  export interface BrowserWaitForResult {
173
+ /** The awaited condition is now true. */
162
174
  readonly found: true
175
+ /** Which state was awaited. */
176
+ readonly state: BrowserWaitForState
177
+ /** The selector waited on, or `''` for a text-only wait. */
163
178
  readonly selector: string
164
- /** Matching element's tag name. */
179
+ /** Matched element's tag name, or `''` when nothing matched (a `detached` wait). */
165
180
  readonly tag: string
166
- /** Matching element's visible text (first 200 chars). */
181
+ /** Matched element's visible text (first 200 chars), or the document text that satisfied a text wait. */
167
182
  readonly text: string
168
183
  }
169
184
 
185
+ /** Print the active tab to a PDF file. */
186
+ export interface BrowserPdfRequest {
187
+ /** Absolute path of the .pdf to write. Must be inside the write roots. */
188
+ readonly savePath: string
189
+ /** Landscape orientation. Default portrait. */
190
+ readonly landscape?: boolean
191
+ /** Include background colours and images. Default true. */
192
+ readonly printBackground?: boolean
193
+ /** Paper width in inches. CDP default is 8.5. */
194
+ readonly paperWidth?: number
195
+ /** Paper height in inches. CDP default is 11. */
196
+ readonly paperHeight?: number
197
+ }
198
+
199
+ /** Where the PDF landed. */
200
+ export interface BrowserPdfResult {
201
+ readonly path: string
202
+ readonly bytes: number
203
+ }
204
+
205
+ /** Draw or clear the DevTools-style highlight box over a selector's first match. */
206
+ export interface BrowserHighlightRequest {
207
+ /** CSS selector whose first match to highlight. Ignored when clear is true. */
208
+ readonly selector?: string
209
+ /** Remove any existing highlight instead of drawing one. */
210
+ readonly clear?: boolean
211
+ }
212
+
213
+ /** What the highlight call found. */
214
+ export interface BrowserHighlightResult {
215
+ /** A match was found and highlighted. */
216
+ readonly matched: boolean
217
+ /** The highlight was cleared. */
218
+ readonly cleared: boolean
219
+ /** CDP node id of the match, when there was one. */
220
+ readonly nodeId?: number
221
+ /** Content box in CSS pixels, when the element has one. */
222
+ readonly box?: { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
223
+ }
224
+
170
225
  /** One field of a batch form fill. Match by selector, or by name/label/placeholder. */
171
226
  export interface BrowserFillField {
172
227
  /** CSS selector; when present, candidates are scoped to it. */
@@ -354,6 +409,18 @@ export interface BrowserSnapshotElement {
354
409
  /** Viewport-relative center, for coordinate fallbacks. */
355
410
  readonly x: number
356
411
  readonly y: number
412
+ /** Index into `frames` when the element lives inside an iframe; absent at the top level. */
413
+ readonly frame?: number
414
+ }
415
+
416
+ /** One iframe found on the page. */
417
+ export interface BrowserSnapshotFrame {
418
+ /** Index into the page's iframe list; matches `BrowserSnapshotElement.frame`. */
419
+ readonly index: number
420
+ /** The frame's src, or its document URL when it has one. */
421
+ readonly url: string
422
+ /** False when the frame is cross-origin, so its contents cannot be read. */
423
+ readonly readable: boolean
357
424
  }
358
425
 
359
426
  /**
@@ -371,6 +438,8 @@ export interface BrowserSnapshotResult {
371
438
  readonly elements: readonly BrowserSnapshotElement[]
372
439
  /** True when the snapshot was truncated (element cap reached). */
373
440
  readonly truncated: boolean
441
+ /** Every iframe on the page, including the cross-origin ones that could not be read. */
442
+ readonly frames?: readonly BrowserSnapshotFrame[]
374
443
  /** Human-verification challenge blocking the page, when one is detected. */
375
444
  readonly challenge?: BrowserChallenge
376
445
  /** True when a human interacted with the page within the last minute. */
@@ -571,6 +640,10 @@ export interface BrowserProvider {
571
640
  uploadFile(session: BrowserSessionId, request: BrowserUploadFileRequest, signal?: AbortSignal): Promise<BrowserUploadFileResult>
572
641
  /** Wait until an element matching the selector exists (and optionally is visible). Honor `signal` for cancellation. */
573
642
  waitForElement(session: BrowserSessionId, request: BrowserWaitForRequest, signal?: AbortSignal): Promise<BrowserWaitForResult>
643
+ /** Print the active tab to a PDF file. */
644
+ pdf(session: BrowserSessionId, request: BrowserPdfRequest, signal?: AbortSignal): Promise<BrowserPdfResult>
645
+ /** Draw or clear the DevTools-style highlight box over a selector's first match. */
646
+ highlight(session: BrowserSessionId, request: BrowserHighlightRequest, signal?: AbortSignal): Promise<BrowserHighlightResult>
574
647
  /** Fill a form's fields in one batch. Honor `signal` for cancellation. */
575
648
  fillForm(session: BrowserSessionId, request: BrowserFillRequest, signal?: AbortSignal): Promise<BrowserFillResult>
576
649
  /** Capture the current page. Honor `signal` for cancellation. */
@@ -615,6 +688,25 @@ export interface BrowserProvider {
615
688
  setHandoff(session: BrowserSessionId, state: BrowserHandoffState): Promise<BrowserTaskInfo>
616
689
  /** Close the session and destroy its backing surface. Idempotent. */
617
690
  close(session: BrowserSessionId): Promise<void>
691
+ /**
692
+ * Bring the shared browser window to the front, creating it (and a default
693
+ * session) when nothing is open yet. Optional: a provider whose browser has
694
+ * no window of its own omits it, and callers must report that as unsupported
695
+ * rather than pretending a window was raised.
696
+ */
697
+ ensureWindowVisible?(): Promise<void>
698
+ /**
699
+ * Mirror one task's Agent todo list into the shared window, where the floating
700
+ * orb renders it. Optional: a provider without a window of its own omits it.
701
+ * `taskKey` is the same task key the tools use (the calling Agent's id).
702
+ */
703
+ pushTaskTodos?(taskKey: string, todos: readonly BrowserTaskTodo[]): Promise<void>
704
+ }
705
+
706
+ /** One entry of an Agent's todo list, as the floating orb shows it. */
707
+ export interface BrowserTaskTodo {
708
+ readonly content: string
709
+ readonly status: 'pending' | 'in_progress' | 'completed'
618
710
  }
619
711
 
620
712
  /** One recorded browser operation, in chronological order (seq 1, 2, 3…). */
@@ -125,17 +125,35 @@ export interface ChromeWorkspaceState {
125
125
  * rounds before this made it visible.
126
126
  */
127
127
  readonly windowProbe?: string
128
+ /** Where the human dragged the floating orb, in page CSS pixels. */
129
+ readonly orbPosition?: { readonly x: number; readonly y: number }
128
130
  }
129
131
 
130
132
  export interface ChromeBootstrapMessage extends ChromeWorkspaceState {
131
133
  readonly kind: 'bootstrap'
132
134
  }
133
135
 
136
+ /**
137
+ * One entry of the Agent's todo list (`todo_write`) as the floating orb renders
138
+ * it: the same three states the model writes, so the orb spins on
139
+ * `in_progress` and ticks `completed` without translating anything.
140
+ */
141
+ export interface ChromeTaskTodo {
142
+ readonly content: string
143
+ readonly status: 'pending' | 'in_progress' | 'completed'
144
+ }
145
+
134
146
  export type ChromePatchOperation =
135
147
  | { readonly op: 'task.upsert'; readonly task: ChromeTaskSummary }
136
148
  | { readonly op: 'task.remove'; readonly key: string }
137
149
  | { readonly op: 'task.active'; readonly key?: string }
138
150
  | { readonly op: 'task.thumbnail'; readonly key: string; readonly version: number; readonly dataUrl?: string }
151
+ /**
152
+ * The Agent's todo list for one task. Like the thumbnail it is NOT part of a
153
+ * summary — summaries are injected into every visited page's main world, and
154
+ * this is the Agent's own plan — so it travels only as a targeted patch.
155
+ */
156
+ | { readonly op: 'task.todos'; readonly key: string; readonly todos: readonly ChromeTaskTodo[] }
139
157
  | { readonly op: 'trail.append'; readonly taskKey: string; readonly entry: ChromeTrailEntry }
140
158
  | { readonly op: 'trail.replace'; readonly taskKey?: string; readonly entries: readonly ChromeTrailEntry[] }
141
159
  | { readonly op: 'panels.set'; readonly panels: ChromePanels }
@@ -49,11 +49,18 @@ export interface Config {
49
49
  */
50
50
  readonly readRoots?: string[]
51
51
  /**
52
- * Which JavaScript world the injected page chrome lives in. `main` (default)
53
- * is the proven path; `isolated` keeps the chrome's task state and its
54
- * binding token out of the page's own context, at the cost of an extra CDP
55
- * context per document. Opt in only after confirming the toolbar in a real
56
- * window (see docs/SOAK-CHECKLIST.md).
52
+ * Which JavaScript world the injected page chrome lives in.
53
+ *
54
+ * `isolated` (default) keeps the chrome's task state — including the Agent's
55
+ * plan, which the floating orb renders — and its binding token out of the
56
+ * page's own context: the page's scripts see `undefined` for every `__dsh*`
57
+ * global instead of a readable copy. It costs one extra CDP context per
58
+ * document, and the chrome reads the DOM through that context (the DOM itself
59
+ * is shared, so element lookups and layout still work).
60
+ *
61
+ * `main` puts everything in the page's world: the proven-against-everything
62
+ * path, but a page can read the plan by hooking `Map.prototype.set` or
63
+ * `document.createElement`. Use it only to bisect an isolated-world bug.
57
64
  */
58
65
  readonly chromeWorld?: 'main' | 'isolated'
59
66
  /**
@@ -82,7 +89,7 @@ export const Config: z<Config> = z.object({
82
89
  // meaning "the documented defaults" while an explicit [] still denies all.
83
90
  writeRoots: z.array(z.string()).default(defaultWriteRoots()),
84
91
  readRoots: z.array(z.string()).default(defaultWriteRoots()),
85
- chromeWorld: z.union(['main', 'isolated'] as const).default('main'),
92
+ chromeWorld: z.union(['main', 'isolated'] as const).default('isolated'),
86
93
  userAgent: z.string(),
87
94
  maskAutomation: z.boolean().default(true),
88
95
  })
@@ -93,7 +100,10 @@ export function apply(ctx: Context & { browser: BrowserRuntime }, config: Config
93
100
  // child is disposed with the fiber, mirroring the shell's lifetime.
94
101
  const host: ElectronBrowserViewHost = config.viewHost
95
102
  ?? new RemoteElectronViewHost(defaultHostMainPath(), {
96
- chromeWorld: config.chromeWorld,
103
+ // `?? 'isolated'` 不只是为了类型:入口可能拿到**未经 schema 解析**的配置
104
+ // (测试探针、其它调用方直接调 apply),那时缺省值不会自动出现,
105
+ // 而 remote-host 把 undefined 当成「不加 --chrome-world」→ 子进程退回 main ✗。
106
+ chromeWorld: config.chromeWorld ?? 'isolated',
97
107
  maskAutomation: config.maskAutomation,
98
108
  ...config.userAgent === undefined ? {} : { userAgent: config.userAgent },
99
109
  })