dsh-builtin-browser 0.1.22 → 0.2.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.en.md +50 -3
- package/README.md +51 -4
- package/client.js +298 -0
- package/cordis.patch.yml +24 -5
- package/docs/README.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/tool-reference.md +2 -1
- package/lib/browser/runtime.d.ts +18 -1
- package/lib/browser/runtime.js +28 -1
- package/lib/browser/types.d.ts +33 -0
- package/lib/browser-electron/entry.js +115 -0
- package/lib/browser-electron/history-store.d.ts +71 -0
- package/lib/browser-electron/history-store.js +151 -0
- package/lib/browser-electron/host-main.d.ts +1 -0
- package/lib/browser-electron/host-main.js +88 -1
- package/lib/browser-electron/provider.d.ts +105 -4
- package/lib/browser-electron/provider.js +226 -14
- package/lib/browser-electron/remote-host.d.ts +29 -6
- package/lib/browser-electron/remote-host.js +114 -24
- package/lib/browser-electron/settings-store.d.ts +87 -0
- package/lib/browser-electron/settings-store.js +160 -0
- package/lib/browser-electron/virtual-cursor.d.ts +42 -0
- package/lib/browser-electron/virtual-cursor.js +71 -0
- package/lib/index.d.ts +16 -2
- package/lib/index.js +18 -2
- package/lib/tool-browser/index.js +72 -2
- package/package.json +20 -6
- package/src/browser/runtime.ts +37 -1
- package/src/browser/types.ts +31 -0
- package/src/browser-electron/entry.ts +135 -0
- package/src/browser-electron/history-store.ts +162 -0
- package/src/browser-electron/host-main.ts +83 -1
- package/src/browser-electron/provider.ts +267 -19
- package/src/browser-electron/remote-host.ts +114 -21
- package/src/browser-electron/settings-store.ts +198 -0
- package/src/browser-electron/virtual-cursor.ts +82 -0
- package/src/index.ts +20 -2
- package/src/tool-browser/index.ts +71 -1
|
@@ -440,12 +440,28 @@ class ElectronChildClient {
|
|
|
440
440
|
private readonly token: string,
|
|
441
441
|
private readonly onExit?: () => void,
|
|
442
442
|
private readonly onUserAction?: (action: BrowserUserAction) => void,
|
|
443
|
+
/**
|
|
444
|
+
* Called when the human closes a browser window. The session it showed is
|
|
445
|
+
* over: the provider releases it, while history and login state stay on disk
|
|
446
|
+
* so the next open is a clean session rather than a resurrected one.
|
|
447
|
+
*/
|
|
448
|
+
private readonly onViewClosed?: (windowId: string) => void,
|
|
443
449
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
444
450
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
445
451
|
private readonly executable?: string,
|
|
446
452
|
) {
|
|
447
453
|
const electron = this.executable ?? resolveElectronPath()
|
|
454
|
+
// Record what is about to be spawned — with existence checks — BEFORE the
|
|
455
|
+
// attempt. When Electron cannot load the app entry it exits 1 with EMPTY
|
|
456
|
+
// stderr, and that signature is otherwise indistinguishable from a missing
|
|
457
|
+
// binary: writing both paths (and whether they exist) in advance is what
|
|
458
|
+
// turns the next occurrence into a named cause instead of a guess.
|
|
448
459
|
process.stderr.write(`[dsh-browser host] spawning electron: ${electron}\n`)
|
|
460
|
+
appendHostLog(
|
|
461
|
+
`${hostStamp()} spawning: electron=${electron} (exists=${String(existsSync(electron))})`
|
|
462
|
+
+ ` hostMain=${this.hostMainPath} (exists=${String(existsSync(this.hostMainPath))})`
|
|
463
|
+
+ ` port=${String(this.port)}\n`,
|
|
464
|
+
)
|
|
449
465
|
// ELECTRON_RUN_AS_NODE (even an empty string) makes Electron run as plain
|
|
450
466
|
// Node, breaking require('electron'); NODE_OPTIONS can inject flags that
|
|
451
467
|
// break the child. Rebuild the env without either.
|
|
@@ -482,15 +498,26 @@ class ElectronChildClient {
|
|
|
482
498
|
// A failed spawn (bad/corrupt binary) emits 'error' — without a listener
|
|
483
499
|
// that would crash the whole DSH process.
|
|
484
500
|
this.child.on('error', error => {
|
|
485
|
-
const line =
|
|
501
|
+
const line = `${hostStamp()} spawn error: ${String(error)} electron=${electron} hostMain=${this.hostMainPath}`
|
|
486
502
|
process.stderr.write(`[dsh-browser host] ${line}\n`)
|
|
487
503
|
appendHostLog(`${line}\n`)
|
|
488
504
|
this.fail(new Error(`dsh-builtin-browser: browser host failed to start: ${String(error)}`))
|
|
489
505
|
})
|
|
490
506
|
this.child.on('exit', (code, signal) => {
|
|
491
507
|
// The exit code/signal is the other half of a crash report; without it a
|
|
492
|
-
// restart loop is indistinguishable from a clean stop.
|
|
493
|
-
|
|
508
|
+
// restart loop is indistinguishable from a clean stop. The timestamp, pid
|
|
509
|
+
// and both paths are what turn a bare `code=1` into a diagnosis: Electron
|
|
510
|
+
// exits 1 with empty stderr precisely when it cannot load the app entry,
|
|
511
|
+
// so the dated `exists=` line written at spawn is the other half.
|
|
512
|
+
const entryExists = existsSync(this.hostMainPath)
|
|
513
|
+
const line = `${hostStamp()} browser host exited (code=${String(code)} signal=${String(signal)})`
|
|
514
|
+
+ ` pid=${String(this.child?.pid)} electron=${electron} hostMain=${this.hostMainPath}`
|
|
515
|
+
+ ` entryExists=${String(entryExists)}`
|
|
516
|
+
// Comparing this with the `exists=` recorded at spawn is the whole
|
|
517
|
+
// diagnosis: present at spawn and absent now means the installation was
|
|
518
|
+
// replaced underneath a running host, which is exactly how Electron ends
|
|
519
|
+
// up unable to load the app entry and exits 1 with empty stderr.
|
|
520
|
+
+ (entryExists ? '' : ' [entry script missing at exit]')
|
|
494
521
|
appendHostLog(`${line}\n`)
|
|
495
522
|
this.fail(new Error(`dsh-builtin-browser: ${line}`))
|
|
496
523
|
})
|
|
@@ -566,7 +593,7 @@ class ElectronChildClient {
|
|
|
566
593
|
const line = this.buffer.slice(0, nl).trim()
|
|
567
594
|
this.buffer = this.buffer.slice(nl + 1)
|
|
568
595
|
if (line === '') continue
|
|
569
|
-
let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown }
|
|
596
|
+
let msg: { id?: number; op?: string; token?: string; ok?: boolean; result?: unknown; err?: string; action?: unknown; windowId?: unknown }
|
|
570
597
|
try {
|
|
571
598
|
msg = JSON.parse(line) as typeof msg
|
|
572
599
|
} catch {
|
|
@@ -580,6 +607,14 @@ class ElectronChildClient {
|
|
|
580
607
|
this.onUserAction?.(msg.action as BrowserUserAction)
|
|
581
608
|
continue
|
|
582
609
|
}
|
|
610
|
+
if (msg.op === 'viewClosed') {
|
|
611
|
+
// Fire-and-forget notification from the child's own UI: the human closed
|
|
612
|
+
// the window, so the interface that session showed is gone. The provider
|
|
613
|
+
// decides what that ends — browsing history and login state survive on
|
|
614
|
+
// disk, only the session itself does not.
|
|
615
|
+
this.onViewClosed?.(typeof msg.windowId === 'string' ? msg.windowId : '')
|
|
616
|
+
continue
|
|
617
|
+
}
|
|
583
618
|
if (this.awaitingHello) {
|
|
584
619
|
// The FIRST line must be the child's hello carrying the spawn token.
|
|
585
620
|
// Anything else (or a wrong token) means a spoofed connection: drop
|
|
@@ -657,6 +692,11 @@ class RemoteView implements ElectronViewHandle {
|
|
|
657
692
|
})
|
|
658
693
|
}
|
|
659
694
|
|
|
695
|
+
/** Give this view web focus in the child, so keyboard input reaches the page. */
|
|
696
|
+
focus(): Promise<void> {
|
|
697
|
+
return this.client.call<void>('focus', { viewId: this.id })
|
|
698
|
+
}
|
|
699
|
+
|
|
660
700
|
/** Ask the child to download a URL to a local file (keeps cookies/login). */
|
|
661
701
|
async download(url: string, savePath: string): Promise<void> {
|
|
662
702
|
// The child writes the file itself (temp + rename); only a small
|
|
@@ -681,6 +721,15 @@ class RemoteView implements ElectronViewHandle {
|
|
|
681
721
|
}
|
|
682
722
|
}
|
|
683
723
|
|
|
724
|
+
/**
|
|
725
|
+
* How long a FAILED Electron-binary probe is remembered before the binary
|
|
726
|
+
* search runs again (`available()`). A success is kept for the host's lifetime.
|
|
727
|
+
*/
|
|
728
|
+
const PROBE_RETRY_MS = (() => {
|
|
729
|
+
const raw = Number(process.env.DSH_BROWSER_PROBE_RETRY_MS)
|
|
730
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30000
|
|
731
|
+
})()
|
|
732
|
+
|
|
684
733
|
/**
|
|
685
734
|
* Self-hosted view host: spawns the plugin's Electron child on first use and
|
|
686
735
|
* keeps it alive until dispose(). Fallback when no desktop shell provides
|
|
@@ -693,40 +742,59 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
693
742
|
private readonly views = new Map<string, ElectronViewHandle>()
|
|
694
743
|
private readyPromise: Promise<void> | undefined
|
|
695
744
|
private disposed = false
|
|
696
|
-
/**
|
|
745
|
+
/** Last probe result. A success is kept for the host's lifetime; a failure
|
|
746
|
+
* is retried after `PROBE_RETRY_MS` (see `available()`). */
|
|
697
747
|
private electronAvailable: boolean | undefined
|
|
748
|
+
/** Earliest time the next probe may run after a negative one. */
|
|
749
|
+
private nextProbeAt = 0
|
|
698
750
|
/** Window groups (windowId per view), re-sent on every materialization so
|
|
699
751
|
* a restarted child still places views in the right windows. */
|
|
700
752
|
private readonly groups = new Map<string, { windowId: string; label?: string }>()
|
|
701
753
|
/** The provider's user-action handler; routes toolbar actions into sessions. */
|
|
702
754
|
private userActionHandler: ((action: BrowserUserAction) => void) | undefined
|
|
755
|
+
/**
|
|
756
|
+
* The provider's handler for a window the human closed. Closing the interface
|
|
757
|
+
* ends the session it showed: history and login state stay on disk, so the
|
|
758
|
+
* next open starts clean instead of resuming a window nobody can see.
|
|
759
|
+
*/
|
|
760
|
+
private viewClosedHandler: ((windowId: string) => void) | undefined
|
|
703
761
|
|
|
704
762
|
constructor(
|
|
705
763
|
private readonly hostMainPath: string,
|
|
706
764
|
/** Test seam: the executable to spawn instead of the resolved Electron
|
|
707
765
|
* binary. Absent -> resolveElectronPath() (production behavior). */
|
|
708
766
|
private readonly spawnExecutable?: string,
|
|
767
|
+
/** Test seam: the binary probe behind `available()`. Absent ->
|
|
768
|
+
* resolveElectronPath(). */
|
|
769
|
+
private readonly probe: () => void = resolveElectronPath,
|
|
709
770
|
) {}
|
|
710
771
|
|
|
711
772
|
/**
|
|
712
773
|
* Cheap usability probe: can we find an Electron binary to spawn? The scan
|
|
713
|
-
* is filesystem-only (no network), per the seam's contract,
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
774
|
+
* is filesystem-only (no network), per the seam's contract, so a missing
|
|
775
|
+
* binary surfaces as `BROWSER_PROVIDER_UNAVAILABLE` at provider selection
|
|
776
|
+
* instead of a confusing spawn failure on first use.
|
|
777
|
+
*
|
|
778
|
+
* A success is cached for the host's lifetime. A failure is NOT: an install
|
|
779
|
+
* (`npm i electron`, or the postinstall that fetches the binary) can land
|
|
780
|
+
* long after DSH started, and provider selection happens once per process —
|
|
781
|
+
* caching "no" permanently reported "no usable browser provider is
|
|
782
|
+
* registered" until DSH restarted, with no way back short of that. Retrying
|
|
783
|
+
* on a cooldown heals it by itself while keeping the scan off the hot path.
|
|
717
784
|
*/
|
|
718
785
|
available(): boolean {
|
|
719
|
-
if (this.electronAvailable ===
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
786
|
+
if (this.electronAvailable === true) return true
|
|
787
|
+
if (Date.now() < this.nextProbeAt) return false
|
|
788
|
+
this.nextProbeAt = Date.now() + PROBE_RETRY_MS
|
|
789
|
+
try {
|
|
790
|
+
this.probe()
|
|
791
|
+
this.electronAvailable = true
|
|
792
|
+
} catch (error) {
|
|
793
|
+
this.electronAvailable = false
|
|
794
|
+
// The provider will be reported unavailable, so the detailed
|
|
795
|
+
// resolution error (install-electron / ELECTRON_PATH guidance) would
|
|
796
|
+
// otherwise never reach the user — surface it on stderr.
|
|
797
|
+
process.stderr.write(`[dsh-browser host] electron unavailable: ${error instanceof Error ? error.message : String(error)}\n`)
|
|
730
798
|
}
|
|
731
799
|
return this.electronAvailable
|
|
732
800
|
}
|
|
@@ -799,6 +867,7 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
799
867
|
token,
|
|
800
868
|
() => this.onChildExit(),
|
|
801
869
|
action => this.userActionHandler?.(action),
|
|
870
|
+
windowId => this.viewClosedHandler?.(windowId),
|
|
802
871
|
this.spawnExecutable,
|
|
803
872
|
)
|
|
804
873
|
if (this.pendingSocket !== undefined) {
|
|
@@ -932,6 +1001,11 @@ export class RemoteElectronViewHost implements ElectronBrowserViewHost {
|
|
|
932
1001
|
this.userActionHandler = handler
|
|
933
1002
|
}
|
|
934
1003
|
|
|
1004
|
+
/** Register the provider's handler for windows the human closed. */
|
|
1005
|
+
onViewClosed(handler: (windowId: string) => void): void {
|
|
1006
|
+
this.viewClosedHandler = handler
|
|
1007
|
+
}
|
|
1008
|
+
|
|
935
1009
|
/** Surface a failed user action to the child's toolbar (address bar etc.). */
|
|
936
1010
|
notifyUserActionError(windowId: string, message: string): void {
|
|
937
1011
|
void this.ready()
|
|
@@ -1027,6 +1101,10 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1027
1101
|
return this.withRecovery(view => view.capture(opts))
|
|
1028
1102
|
}
|
|
1029
1103
|
|
|
1104
|
+
async focus(): Promise<void> {
|
|
1105
|
+
return this.withRecovery(view => view.focus())
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1030
1108
|
async flushAuth(): Promise<ExportedCookie[]> {
|
|
1031
1109
|
return this.withRecovery(view => view.flushAuth())
|
|
1032
1110
|
}
|
|
@@ -1049,6 +1127,16 @@ class DeferredRemoteView implements ElectronViewHandle {
|
|
|
1049
1127
|
*/
|
|
1050
1128
|
const HOST_LOG_MAX_BYTES = 2 * 1024 * 1024
|
|
1051
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* ISO timestamp prefix for host-log lines. This log is the only place a spawn
|
|
1132
|
+
* failure is recorded, and an undated line cannot be lined up with anything else
|
|
1133
|
+
* that happened on the machine — a plugin update replacing the entry script, for
|
|
1134
|
+
* instance — which is precisely the question this log gets asked.
|
|
1135
|
+
*/
|
|
1136
|
+
function hostStamp(): string {
|
|
1137
|
+
return new Date().toISOString()
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1052
1140
|
function appendHostLog(text: string): void {
|
|
1053
1141
|
const home = process.env.DSH_HOME
|
|
1054
1142
|
if (home === undefined || home === '') return
|
|
@@ -1056,7 +1144,12 @@ function appendHostLog(text: string): void {
|
|
|
1056
1144
|
const dir = join(home, 'logs')
|
|
1057
1145
|
const file = join(dir, 'dsh-builtin-browser-host.log')
|
|
1058
1146
|
try {
|
|
1059
|
-
if (statSync(file).size > HOST_LOG_MAX_BYTES)
|
|
1147
|
+
if (statSync(file).size > HOST_LOG_MAX_BYTES) {
|
|
1148
|
+
// Discard the old bytes but leave a dated marker: truncating to empty
|
|
1149
|
+
// makes a rotation look like "nothing ever happened", which is how the
|
|
1150
|
+
// history of a crash loop gets silently swallowed.
|
|
1151
|
+
writeFileSync(file, `${hostStamp()} log rotated: previous content exceeded ${String(HOST_LOG_MAX_BYTES)} bytes and was discarded\n`)
|
|
1152
|
+
}
|
|
1060
1153
|
} catch { /* first write, or unreadable: just append */ }
|
|
1061
1154
|
mkdirSync(dir, { recursive: true })
|
|
1062
1155
|
appendFileSync(file, text)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin-owned settings document — the switches the browser settings panel
|
|
3
|
+
* exposes, stored next to the browsing history so both follow the same rules:
|
|
4
|
+
* they survive the browser process and DSH restarts.
|
|
5
|
+
*
|
|
6
|
+
* Every field is optional on disk and falls back to a default, so a hand-edited
|
|
7
|
+
* or older file never breaks startup; an unreadable file is treated as "all
|
|
8
|
+
* defaults" rather than an error.
|
|
9
|
+
* @module dsh-browser/browser-electron/settings-store
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
/** Vision/operation strategy the provider prefers when the model can see. */
|
|
17
|
+
export type VisionStrategy = 'auto' | 'nonVisual'
|
|
18
|
+
|
|
19
|
+
/** The full settings document, with every field resolved. */
|
|
20
|
+
export interface BrowserSettings {
|
|
21
|
+
/** Persistent browsing history (visited pages). */
|
|
22
|
+
readonly history: {
|
|
23
|
+
readonly enabled: boolean
|
|
24
|
+
readonly maxEntries: number
|
|
25
|
+
readonly maxAgeDays: number
|
|
26
|
+
}
|
|
27
|
+
/** Login-state retention: cookies and the browser profile. */
|
|
28
|
+
readonly cookies: {
|
|
29
|
+
readonly persist: boolean
|
|
30
|
+
}
|
|
31
|
+
/** Presentation behaviour. */
|
|
32
|
+
readonly ui: {
|
|
33
|
+
/** Auto-expand the side panel once per task, then leave the user alone. */
|
|
34
|
+
readonly autoExpandOnce: boolean
|
|
35
|
+
/** Release the browser when its session ends, instead of waiting for a close. */
|
|
36
|
+
readonly closeWithSession: boolean
|
|
37
|
+
/** Show the synthetic cursor/click feedback while the agent operates. */
|
|
38
|
+
readonly virtualCursor: boolean
|
|
39
|
+
}
|
|
40
|
+
/** Visual vs non-visual operation strategy. */
|
|
41
|
+
readonly vision: {
|
|
42
|
+
readonly strategy: VisionStrategy
|
|
43
|
+
}
|
|
44
|
+
/** Whether the agent may read cookies / export login state. */
|
|
45
|
+
readonly credentials: {
|
|
46
|
+
readonly allowRead: boolean
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
51
|
+
export const DEFAULT_SETTINGS: BrowserSettings = {
|
|
52
|
+
history: { enabled: true, maxEntries: 5_000, maxAgeDays: 90 },
|
|
53
|
+
cookies: { persist: true },
|
|
54
|
+
ui: { autoExpandOnce: true, closeWithSession: false, virtualCursor: true },
|
|
55
|
+
vision: { strategy: 'auto' },
|
|
56
|
+
credentials: { allowRead: true },
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute path of the settings file, beside the browser profile and the
|
|
61
|
+
* browsing history.
|
|
62
|
+
* @returns the settings file path.
|
|
63
|
+
*/
|
|
64
|
+
export function settingsPath(): string {
|
|
65
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
66
|
+
return join(home, 'dsh-builtin-browser-host', 'settings.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Coerce one boolean field, keeping the default when absent or mistyped. */
|
|
70
|
+
function bool(value: unknown, fallback: boolean): boolean {
|
|
71
|
+
return typeof value === 'boolean' ? value : fallback
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
75
|
+
function count(value: unknown, fallback: number): number {
|
|
76
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Read one nested section of an unknown document, or an empty object. */
|
|
80
|
+
function section(source: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
81
|
+
const value = source[key]
|
|
82
|
+
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
87
|
+
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
88
|
+
* smuggle configuration.
|
|
89
|
+
* @param raw - the parsed file contents.
|
|
90
|
+
* @returns the resolved settings.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveSettings(raw: unknown): BrowserSettings {
|
|
93
|
+
const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
|
|
94
|
+
const history = section(source, 'history')
|
|
95
|
+
const cookies = section(source, 'cookies')
|
|
96
|
+
const ui = section(source, 'ui')
|
|
97
|
+
const vision = section(source, 'vision')
|
|
98
|
+
const credentials = section(source, 'credentials')
|
|
99
|
+
return {
|
|
100
|
+
history: {
|
|
101
|
+
enabled: bool(history.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
102
|
+
maxEntries: count(history.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
|
|
103
|
+
maxAgeDays: count(history.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
|
|
104
|
+
},
|
|
105
|
+
cookies: { persist: bool(cookies.persist, DEFAULT_SETTINGS.cookies.persist) },
|
|
106
|
+
ui: {
|
|
107
|
+
autoExpandOnce: bool(ui.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
|
|
108
|
+
closeWithSession: bool(ui.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
|
|
109
|
+
virtualCursor: bool(ui.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
110
|
+
},
|
|
111
|
+
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
112
|
+
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
118
|
+
* edit from another process (or the settings panel writing through a second
|
|
119
|
+
* code path) is picked up without a restart; writes are atomic enough for a
|
|
120
|
+
* single-writer plugin and never throw at the caller.
|
|
121
|
+
*/
|
|
122
|
+
export class SettingsStore {
|
|
123
|
+
private cached: BrowserSettings | undefined
|
|
124
|
+
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
125
|
+
private cachedMtimeMs = -1
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param file - settings file path (defaults beside the browser profile).
|
|
129
|
+
*/
|
|
130
|
+
constructor(private readonly file: string = settingsPath()) {}
|
|
131
|
+
|
|
132
|
+
/** Path this store reads and writes. */
|
|
133
|
+
path(): string {
|
|
134
|
+
return this.file
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Current settings, re-read when the file changed on disk.
|
|
139
|
+
* @returns the resolved settings document.
|
|
140
|
+
*/
|
|
141
|
+
get(): BrowserSettings {
|
|
142
|
+
let mtimeMs = -1
|
|
143
|
+
let text: string | undefined
|
|
144
|
+
try {
|
|
145
|
+
if (existsSync(this.file)) {
|
|
146
|
+
mtimeMs = statSync(this.file).mtimeMs
|
|
147
|
+
text = readFileSync(this.file, 'utf8')
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// Unreadable file: fall through to defaults below.
|
|
151
|
+
mtimeMs = -1
|
|
152
|
+
text = undefined
|
|
153
|
+
}
|
|
154
|
+
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
|
|
155
|
+
try {
|
|
156
|
+
this.cached = text === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(text))
|
|
157
|
+
} catch {
|
|
158
|
+
// Malformed JSON: behave as defaults rather than fail startup.
|
|
159
|
+
this.cached = DEFAULT_SETTINGS
|
|
160
|
+
}
|
|
161
|
+
this.cachedMtimeMs = mtimeMs
|
|
162
|
+
return this.cached
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Merge a partial patch into the stored document and persist it.
|
|
167
|
+
* @param patch - the fields to change (unknown keys are ignored).
|
|
168
|
+
* @returns the settings after the merge.
|
|
169
|
+
*/
|
|
170
|
+
update(patch: unknown): BrowserSettings {
|
|
171
|
+
const current = this.get()
|
|
172
|
+
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
|
|
173
|
+
try {
|
|
174
|
+
mkdirSync(dirname(this.file), { recursive: true })
|
|
175
|
+
writeFileSync(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
|
176
|
+
} catch {
|
|
177
|
+
// Keep the merged value in memory even when the write fails.
|
|
178
|
+
}
|
|
179
|
+
this.cached = merged
|
|
180
|
+
this.cachedMtimeMs = Date.now()
|
|
181
|
+
return merged
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One-level-deep merge over the settings sections (patch wins). */
|
|
186
|
+
function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
|
|
187
|
+
if (typeof patch !== 'object' || patch === null) return base
|
|
188
|
+
const result: Record<string, unknown> = { ...base }
|
|
189
|
+
for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
|
|
190
|
+
const existing = result[key]
|
|
191
|
+
if (typeof value === 'object' && value !== null && typeof existing === 'object' && existing !== null) {
|
|
192
|
+
result[key] = { ...existing as Record<string, unknown>, ...value as Record<string, unknown> }
|
|
193
|
+
} else {
|
|
194
|
+
result[key] = value
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result
|
|
198
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic pointer feedback for a human watching the tab: an overlay drawn
|
|
3
|
+
* INSIDE the page, never the real system pointer. Its presence is the "the agent
|
|
4
|
+
* has taken over this tab" signal, so it also covers DOM-level operations that
|
|
5
|
+
* move no pointer at all.
|
|
6
|
+
*
|
|
7
|
+
* Implementation notes that matter:
|
|
8
|
+
* - The overlay is `position: fixed` and positioned from viewport coordinates,
|
|
9
|
+
* which is exactly what the element locator already computes and what
|
|
10
|
+
* `Input.dispatchMouseEvent` consumes.
|
|
11
|
+
* - All styling is inline and the ripple uses the Web Animations API: a
|
|
12
|
+
* `<style>` element would be subject to the page's `style-src` CSP, an
|
|
13
|
+
* injected script is not. Injected page feedback must never be silently
|
|
14
|
+
* dropped by a strict CSP.
|
|
15
|
+
* - Every call is best-effort: feedback is never allowed to fail an operation.
|
|
16
|
+
* @module dsh-browser/browser-electron/virtual-cursor
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** What the pointer is doing at the reported point. */
|
|
20
|
+
export type CursorAction = 'move' | 'click'
|
|
21
|
+
|
|
22
|
+
/** Element id of the overlay root, stable so repeat calls update one node. */
|
|
23
|
+
const CURSOR_ID = '__dsh_agent_cursor__'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The in-page expression that materializes or moves the overlay. Idempotent: the
|
|
27
|
+
* first call creates the nodes, later calls only move them (and pulse).
|
|
28
|
+
* @param x - viewport x in CSS pixels.
|
|
29
|
+
* @param y - viewport y in CSS pixels.
|
|
30
|
+
* @param action - whether to pulse a click ripple at the point.
|
|
31
|
+
* @returns a self-contained expression that evaluates to `true`.
|
|
32
|
+
*/
|
|
33
|
+
export function cursorScript(x: number, y: number, action: CursorAction): string {
|
|
34
|
+
return `(() => {
|
|
35
|
+
const id = ${JSON.stringify(CURSOR_ID)}
|
|
36
|
+
let root = document.getElementById(id)
|
|
37
|
+
if (!root) {
|
|
38
|
+
root = document.createElement('div')
|
|
39
|
+
root.id = id
|
|
40
|
+
root.setAttribute('aria-hidden', 'true')
|
|
41
|
+
root.style.cssText = 'position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none;transition:transform 120ms ease-out;will-change:transform'
|
|
42
|
+
const svg = '<svg width="22" height="22" viewBox="0 0 22 22" style="position:absolute;left:-2px;top:-2px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))">'
|
|
43
|
+
+ '<path d="M4 2 L4 18 L8.4 13.8 L11.2 19.6 L13.8 18.4 L11 12.8 L17 12.6 Z" fill="#ffffff" stroke="#111111" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
44
|
+
const ripple = '<span style="position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid rgba(64,160,255,.9);opacity:0"></span>'
|
|
45
|
+
root.innerHTML = svg + ripple
|
|
46
|
+
;(document.body || document.documentElement).appendChild(root)
|
|
47
|
+
}
|
|
48
|
+
root.style.transform = 'translate(${x}px, ${y}px)'
|
|
49
|
+
${action === 'click' ? `const ripple = root.lastElementChild
|
|
50
|
+
if (ripple && typeof ripple.animate === 'function') {
|
|
51
|
+
ripple.animate(
|
|
52
|
+
[{ transform: 'scale(.25)', opacity: 0.95 }, { transform: 'scale(1.6)', opacity: 0 }],
|
|
53
|
+
{ duration: 550, easing: 'ease-out' },
|
|
54
|
+
)
|
|
55
|
+
}` : ''}
|
|
56
|
+
return true
|
|
57
|
+
})()`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Draw (or move) the pointer overlay on one view's page.
|
|
62
|
+
*
|
|
63
|
+
* Fire-and-forget: a page that is mid-navigation, cross-origin-blocked, or
|
|
64
|
+
* otherwise unable to answer must not turn a successful operation into a
|
|
65
|
+
* failure, and the navigation that just committed may still be replacing the
|
|
66
|
+
* document this call would have painted into.
|
|
67
|
+
* @param handle - the view to paint into.
|
|
68
|
+
* @param x - viewport x in CSS pixels.
|
|
69
|
+
* @param y - viewport y in CSS pixels.
|
|
70
|
+
* @param action - whether to pulse a click ripple.
|
|
71
|
+
* @param evaluate - the provider's evaluate bridge (keeps the CDP coupling in one place).
|
|
72
|
+
*/
|
|
73
|
+
export function paintCursor<TView>(
|
|
74
|
+
handle: TView,
|
|
75
|
+
x: number,
|
|
76
|
+
y: number,
|
|
77
|
+
action: CursorAction,
|
|
78
|
+
evaluate: (handle: TView, expression: string) => Promise<unknown>,
|
|
79
|
+
): void {
|
|
80
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
|
81
|
+
void evaluate(handle, cursorScript(Math.round(x), Math.round(y), action)).catch(() => {})
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,11 +4,29 @@
|
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
6
|
* - `dsh-builtin-browser/tool-browser` -> the model-facing browser_* tools
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
7
|
+
* The capability itself is composed by those rows; this root entry exists for
|
|
8
|
+
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
|
+
* package's browser half through a loader row whose specifier resolves to the
|
|
10
|
+
* PACKAGE ROOT. A row named after a subpath (`dsh-builtin-browser/browser`) is
|
|
11
|
+
* not a package specifier and is skipped by design, which is why the patch also
|
|
12
|
+
* registers a row under the bare package name — see cordis.patch.yml.
|
|
9
13
|
* @module dsh-builtin-browser
|
|
10
14
|
*/
|
|
11
15
|
|
|
16
|
+
/** Plugin identity for the root row (the composition surface is the subpath rows). */
|
|
17
|
+
export const name = 'dsh-builtin-browser'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inert application for the root row: the browser seam, the Electron provider and
|
|
21
|
+
* the tools are mounted by the subpath rows, so this one only exists to give the
|
|
22
|
+
* package a loader row named after the package itself. Without it the host's
|
|
23
|
+
* client scan finds no row to resolve `dsh.client` from, and the settings panel
|
|
24
|
+
* never reaches the browser (the plugin works, its client half is invisible).
|
|
25
|
+
*/
|
|
26
|
+
export function apply(): void {
|
|
27
|
+
// Intentionally empty: see the module comment above.
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export { BrowserError } from './browser/types.js'
|
|
13
31
|
export type {
|
|
14
32
|
BrowserA11yNode,
|
|
@@ -105,7 +105,12 @@ function taskKey(exec: { agent?: { id?: string } } | undefined): string {
|
|
|
105
105
|
*/
|
|
106
106
|
async function ensureSession(browser: NonNullable<Context['browser']>, state: ToolBrowserState, key: string, agent?: ExecAgent): Promise<BrowserSessionId> {
|
|
107
107
|
const existing = state.sessionsByTask.get(key)
|
|
108
|
-
if (existing !== undefined)
|
|
108
|
+
if (existing !== undefined) {
|
|
109
|
+
// The human closing a browser window ends that session, so a cached id can
|
|
110
|
+
// be stale: never hand a dead session back to a tool call.
|
|
111
|
+
if (browser.exists(existing)) return existing
|
|
112
|
+
state.sessionsByTask.delete(key)
|
|
113
|
+
}
|
|
109
114
|
const pending = state.pendingOpens.get(key)
|
|
110
115
|
if (pending !== undefined) return pending
|
|
111
116
|
// The task key rides along as the session label so the window title shows
|
|
@@ -1327,6 +1332,71 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
1327
1332
|
},
|
|
1328
1333
|
}))
|
|
1329
1334
|
|
|
1335
|
+
ctx.tools.register(defineTool({
|
|
1336
|
+
name: 'browser_visited',
|
|
1337
|
+
description: 'List pages the shared browser has visited, newest first, from the PERSISTENT browsing history: it survives closing the browser and restarting DSH, so it is how you (or the human) find a page again afterwards. Reopen one by passing its url to browser_open. Not to be confused with browser_history, which is only the current session\'s operation log (navigate/click/type) and disappears with the session.',
|
|
1338
|
+
parameters: {
|
|
1339
|
+
limit: { type: 'number', description: 'Maximum entries to return (default 30, capped at 200).' },
|
|
1340
|
+
domain: { type: 'string', description: 'Only visits whose hostname contains this text (case-insensitive).' },
|
|
1341
|
+
},
|
|
1342
|
+
output: {
|
|
1343
|
+
schema: {
|
|
1344
|
+
type: 'object',
|
|
1345
|
+
additionalProperties: false,
|
|
1346
|
+
properties: {
|
|
1347
|
+
count: { type: 'number', required: true },
|
|
1348
|
+
entries: {
|
|
1349
|
+
type: 'array',
|
|
1350
|
+
required: true,
|
|
1351
|
+
items: {
|
|
1352
|
+
type: 'object',
|
|
1353
|
+
additionalProperties: false,
|
|
1354
|
+
properties: {
|
|
1355
|
+
at: { type: 'number', required: true },
|
|
1356
|
+
url: { type: 'string', required: true },
|
|
1357
|
+
title: { type: 'string' },
|
|
1358
|
+
session: { type: 'string' },
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
render: (_args, value) => {
|
|
1365
|
+
const entries = value.entries as Array<{ at: number; url: string; title?: string; session?: string }>
|
|
1366
|
+
if (entries.length === 0) {
|
|
1367
|
+
return [{ type: 'text', text: '(no recorded visits — nothing has been browsed yet, or history recording is turned off in settings)' }]
|
|
1368
|
+
}
|
|
1369
|
+
return [{
|
|
1370
|
+
type: 'text',
|
|
1371
|
+
text: entries.map(entry => {
|
|
1372
|
+
const when = new Date(entry.at).toISOString().replace('T', ' ').slice(0, 19)
|
|
1373
|
+
const title = entry.title !== undefined ? `${entry.title} — ` : ''
|
|
1374
|
+
return `${when} ${title}${entry.url}${entry.session !== undefined ? ` [${entry.session}]` : ''}`
|
|
1375
|
+
}).join('\n'),
|
|
1376
|
+
}]
|
|
1377
|
+
},
|
|
1378
|
+
},
|
|
1379
|
+
timeoutMs,
|
|
1380
|
+
isConcurrencySafe: () => true,
|
|
1381
|
+
async execute(args, _exec) {
|
|
1382
|
+
const browser = ctx.get('browser')
|
|
1383
|
+
if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
|
|
1384
|
+
const rawLimit = typeof args.limit === 'number' && Number.isFinite(args.limit) ? args.limit : 30
|
|
1385
|
+
const limit = Math.max(1, Math.min(200, Math.floor(rawLimit)))
|
|
1386
|
+
const domain = typeof args.domain === 'string' && args.domain.trim() !== '' ? args.domain.trim() : undefined
|
|
1387
|
+
const entries = browser.visited({ limit, ...domain !== undefined ? { domain } : {} })
|
|
1388
|
+
return {
|
|
1389
|
+
count: entries.length,
|
|
1390
|
+
entries: entries.map(entry => ({
|
|
1391
|
+
at: entry.at,
|
|
1392
|
+
url: entry.url,
|
|
1393
|
+
...entry.title !== undefined ? { title: entry.title } : {},
|
|
1394
|
+
...entry.session !== undefined ? { session: entry.session } : {},
|
|
1395
|
+
})),
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
}))
|
|
1399
|
+
|
|
1330
1400
|
ctx.tools.register(defineTool({
|
|
1331
1401
|
name: 'browser_replay',
|
|
1332
1402
|
description: 'Replay one recorded browser operation by its history sequence number (from browser_history). Navigate/click/type are re-issued against the current page; execute re-runs its script. The replayed step is appended to history as a new entry.',
|