dsh-builtin-browser 0.4.1 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1699 -0
- package/README.en.md +114 -33
- package/README.md +108 -32
- package/client.js +478 -29
- package/cordis.patch.yml +21 -0
- package/desktop-bridge/install.mjs +66 -6
- package/desktop-bridge/plugin-browser-bridge.js +426 -167
- package/docs/architecture.md +14 -4
- package/docs/tool-reference.md +13 -13
- package/docs/user-guide.md +58 -3
- package/docs/why-browser.md +1 -1
- package/lib/browser/types.d.ts +34 -3
- package/lib/browser-electron/bridge-connection.d.ts +49 -26
- package/lib/browser-electron/bridge-connection.js +185 -22
- package/lib/browser-electron/desktop-bridge-host.d.ts +69 -36
- package/lib/browser-electron/desktop-bridge-host.js +151 -86
- package/lib/browser-electron/entry.d.ts +1 -1
- package/lib/browser-electron/entry.js +133 -40
- package/lib/browser-electron/ephemeral-profile.d.ts +37 -0
- package/lib/browser-electron/ephemeral-profile.js +146 -0
- package/lib/browser-electron/history-store.d.ts +36 -2
- package/lib/browser-electron/history-store.js +76 -7
- package/lib/browser-electron/host-main.js +86 -28
- package/lib/browser-electron/provider.d.ts +106 -3
- package/lib/browser-electron/provider.js +407 -60
- package/lib/browser-electron/remote-host.d.ts +52 -3
- package/lib/browser-electron/remote-host.js +259 -25
- package/lib/browser-electron/rpc-ops.d.ts +18 -0
- package/lib/browser-electron/rpc-ops.js +36 -0
- package/lib/browser-electron/settings-route.d.ts +28 -0
- package/lib/browser-electron/settings-route.js +68 -0
- package/lib/browser-electron/settings-store.d.ts +40 -0
- package/lib/browser-electron/settings-store.js +160 -23
- package/lib/browser-electron/system-browser.d.ts +102 -12
- package/lib/browser-electron/system-browser.js +411 -51
- package/lib/index.js +0 -1
- package/lib/tool-browser/element-fields.d.ts +145 -0
- package/lib/tool-browser/element-fields.js +152 -0
- package/lib/tool-browser/index.js +149 -74
- package/package.json +10 -4
- package/scripts/verify-host-compat.mjs +195 -0
- package/src/browser/types.ts +34 -3
- package/src/browser-electron/bridge-connection.ts +179 -22
- package/src/browser-electron/desktop-bridge-host.ts +146 -84
- package/src/browser-electron/entry.ts +132 -39
- package/src/browser-electron/ephemeral-profile.ts +152 -0
- package/src/browser-electron/history-store.ts +81 -9
- package/src/browser-electron/host-main.ts +115 -36
- package/src/browser-electron/provider.ts +492 -69
- package/src/browser-electron/remote-host.ts +276 -38
- package/src/browser-electron/rpc-ops.ts +41 -0
- package/src/browser-electron/settings-route.ts +87 -0
- package/src/browser-electron/settings-store.ts +173 -24
- package/src/browser-electron/system-browser.ts +408 -39
- package/src/index.ts +3 -2
- package/src/tool-browser/element-fields.ts +167 -0
- package/src/tool-browser/index.ts +155 -76
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* @module dsh-browser/browser-electron/settings-store
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { dirname, join } from 'node:path'
|
|
15
15
|
import type { BrowserChannel } from './system-browser.js'
|
|
@@ -50,6 +50,22 @@ export interface BrowserSettings {
|
|
|
50
50
|
readonly credentials: {
|
|
51
51
|
readonly allowRead: boolean
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* What the agent is allowed to DO, as opposed to what it may look at.
|
|
55
|
+
*
|
|
56
|
+
* These are OPERATOR switches: the settings panel is not reachable from the model, so a
|
|
57
|
+
* value here refuses whatever the model asks for, and no tool can lift it. That is what
|
|
58
|
+
* separates them from `browser_restrict`, which is the model's own soft guardrail. All
|
|
59
|
+
* three default to on, which is the documented out-of-the-box behaviour.
|
|
60
|
+
*/
|
|
61
|
+
readonly actions: {
|
|
62
|
+
/** Run arbitrary page scripts in the active tab (`browser_execute`). */
|
|
63
|
+
readonly allowExecute: boolean
|
|
64
|
+
/** Download a URL to disk (`browser_download`). */
|
|
65
|
+
readonly allowDownload: boolean
|
|
66
|
+
/** Write cookies — importing login state (`browser_auth` restore). */
|
|
67
|
+
readonly allowCredentialWrite: boolean
|
|
68
|
+
}
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
/** Defaults for every switch: the documented out-of-the-box behaviour. */
|
|
@@ -63,6 +79,7 @@ export const DEFAULT_SETTINGS: BrowserSettings = {
|
|
|
63
79
|
// it starts a real Chrome/Edge with its own profile.
|
|
64
80
|
browser: { channel: 'bundled' },
|
|
65
81
|
credentials: { allowRead: true },
|
|
82
|
+
actions: { allowExecute: true, allowDownload: true, allowCredentialWrite: true },
|
|
66
83
|
}
|
|
67
84
|
|
|
68
85
|
/**
|
|
@@ -80,6 +97,38 @@ function bool(value: unknown, fallback: boolean): boolean {
|
|
|
80
97
|
return typeof value === 'boolean' ? value : fallback
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Coerce one boolean field that GATES A CAPABILITY.
|
|
102
|
+
*
|
|
103
|
+
* The difference from {@link bool} is what a mistyped value means. Absent still takes the
|
|
104
|
+
* default — a document written before the switch existed is the documented behaviour. But
|
|
105
|
+
* present-and-not-a-boolean is a document this code cannot read, and for a switch that gates
|
|
106
|
+
* something the operator chose to limit, unreadable resolves to OFF. `bool`'s fallback made
|
|
107
|
+
* `"false"` (a string), `null` (a truncated write) and `1` all mean the same as `true`, so a
|
|
108
|
+
* single dropped byte reopened every capability that had been turned off.
|
|
109
|
+
* @param value - the raw value from the settings document.
|
|
110
|
+
* @param fallback - the default, used only when the field is absent.
|
|
111
|
+
* @returns the value when it is a boolean, `false` when it is anything else.
|
|
112
|
+
*/
|
|
113
|
+
function gate(value: unknown, fallback: boolean): boolean {
|
|
114
|
+
if (value === undefined) return fallback
|
|
115
|
+
return value === true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What an unreadable document resolves to.
|
|
120
|
+
*
|
|
121
|
+
* Only the capability gates are refused; everything else keeps its default. The distinction is
|
|
122
|
+
* deliberate: refusing history, presentation or browser choice would punish the user for a
|
|
123
|
+
* corrupt file without protecting anything, while refusing the gates is the whole point —
|
|
124
|
+
* "the operator's intent cannot be read" must not resolve to "the operator permits".
|
|
125
|
+
*/
|
|
126
|
+
const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
|
|
127
|
+
...DEFAULT_SETTINGS,
|
|
128
|
+
credentials: { allowRead: false },
|
|
129
|
+
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
/**
|
|
84
133
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
85
134
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
@@ -87,20 +136,42 @@ function bool(value: unknown, fallback: boolean): boolean {
|
|
|
87
136
|
* @returns a valid channel.
|
|
88
137
|
*/
|
|
89
138
|
function resolveChannel(value: unknown): BrowserChannel {
|
|
90
|
-
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled'
|
|
139
|
+
return value === 'chrome' || value === 'edge' || value === 'auto' || value === 'bundled' || value === 'sidebar'
|
|
91
140
|
? value
|
|
92
141
|
: DEFAULT_SETTINGS.browser.channel
|
|
93
142
|
}
|
|
94
143
|
|
|
95
144
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
96
145
|
function count(value: unknown, fallback: number): number {
|
|
97
|
-
|
|
146
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
|
147
|
+
const floored = Math.floor(value)
|
|
148
|
+
// A fractional value below 1 floors to 0, and 0 is not "very few" in either place these are
|
|
149
|
+
// used: as a history entry cap it means `slice(len - 0)`, i.e. keep nothing, so a hand-typed
|
|
150
|
+
// `0.5` silently wiped the history file on the next rewrite. Anything that survives the
|
|
151
|
+
// floor has to still be a usable limit.
|
|
152
|
+
return floored > 0 ? floored : fallback
|
|
98
153
|
}
|
|
99
154
|
|
|
100
155
|
/** Read one nested section of an unknown document, or an empty object. */
|
|
101
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Read one section of the document, distinguishing ABSENT from UNREADABLE.
|
|
158
|
+
*
|
|
159
|
+
* Absent (the key is not there at all) is a document written before the section existed, and
|
|
160
|
+
* the defaults apply — that is the documented behaviour. Present-but-not-an-object
|
|
161
|
+
* (`"actions": false`, `null` from a truncated write, a stray string) is a document this code
|
|
162
|
+
* cannot read, and returning `{}` for it made every gate inside look absent, so each one took
|
|
163
|
+
* its permissive default. A whole section mistyped is exactly the shape a careless hand edit
|
|
164
|
+
* produces, and it opened every capability the section was there to bound.
|
|
165
|
+
* @param source - the parsed document.
|
|
166
|
+
* @param key - the section name.
|
|
167
|
+
* @returns the section, or undefined when it is present but not an object.
|
|
168
|
+
*/
|
|
169
|
+
function section(source: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
|
|
102
170
|
const value = source[key]
|
|
103
|
-
|
|
171
|
+
if (value === undefined) return {}
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
173
|
+
? value as Record<string, unknown>
|
|
174
|
+
: undefined
|
|
104
175
|
}
|
|
105
176
|
|
|
106
177
|
/**
|
|
@@ -118,21 +189,44 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
118
189
|
const vision = section(source, 'vision')
|
|
119
190
|
const credentials = section(source, 'credentials')
|
|
120
191
|
const browser = section(source, 'browser')
|
|
192
|
+
const actions = section(source, 'actions')
|
|
193
|
+
// A section that is present but unreadable refuses its gates rather than defaulting them
|
|
194
|
+
// open. The rest of a broken section keeps its defaults: refusing history or presentation
|
|
195
|
+
// would punish the user for a bad file without protecting anything.
|
|
196
|
+
const credentialsUsable = credentials ?? {}
|
|
197
|
+
const actionsUsable = actions ?? {}
|
|
121
198
|
return {
|
|
122
199
|
history: {
|
|
123
|
-
enabled: bool(history
|
|
124
|
-
maxEntries: count(history
|
|
125
|
-
maxAgeDays: count(history
|
|
200
|
+
enabled: bool(history?.enabled, DEFAULT_SETTINGS.history.enabled),
|
|
201
|
+
maxEntries: count(history?.maxEntries, DEFAULT_SETTINGS.history.maxEntries),
|
|
202
|
+
maxAgeDays: count(history?.maxAgeDays, DEFAULT_SETTINGS.history.maxAgeDays),
|
|
126
203
|
},
|
|
127
|
-
cookies: { persist: bool(cookies
|
|
204
|
+
cookies: { persist: bool(cookies?.persist, DEFAULT_SETTINGS.cookies.persist) },
|
|
128
205
|
ui: {
|
|
129
|
-
autoExpandOnce: bool(ui
|
|
130
|
-
closeWithSession: bool(ui
|
|
131
|
-
virtualCursor: bool(ui
|
|
206
|
+
autoExpandOnce: bool(ui?.autoExpandOnce, DEFAULT_SETTINGS.ui.autoExpandOnce),
|
|
207
|
+
closeWithSession: bool(ui?.closeWithSession, DEFAULT_SETTINGS.ui.closeWithSession),
|
|
208
|
+
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
209
|
+
},
|
|
210
|
+
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
211
|
+
browser: { channel: resolveChannel(browser?.channel) },
|
|
212
|
+
credentials: {
|
|
213
|
+
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
214
|
+
// is refused here for free: the section itself is the "present but wrong" value.
|
|
215
|
+
allowRead: credentials === undefined
|
|
216
|
+
? false
|
|
217
|
+
: gate(credentialsUsable.allowRead, DEFAULT_SETTINGS.credentials.allowRead),
|
|
218
|
+
},
|
|
219
|
+
actions: {
|
|
220
|
+
allowExecute: actions === undefined
|
|
221
|
+
? false
|
|
222
|
+
: gate(actionsUsable.allowExecute, DEFAULT_SETTINGS.actions.allowExecute),
|
|
223
|
+
allowDownload: actions === undefined
|
|
224
|
+
? false
|
|
225
|
+
: gate(actionsUsable.allowDownload, DEFAULT_SETTINGS.actions.allowDownload),
|
|
226
|
+
allowCredentialWrite: actions === undefined
|
|
227
|
+
? false
|
|
228
|
+
: gate(actionsUsable.allowCredentialWrite, DEFAULT_SETTINGS.actions.allowCredentialWrite),
|
|
132
229
|
},
|
|
133
|
-
vision: { strategy: vision.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
134
|
-
browser: { channel: resolveChannel(browser.channel) },
|
|
135
|
-
credentials: { allowRead: bool(credentials.allowRead, DEFAULT_SETTINGS.credentials.allowRead) },
|
|
136
230
|
}
|
|
137
231
|
}
|
|
138
232
|
|
|
@@ -163,18 +257,33 @@ export class SettingsStore {
|
|
|
163
257
|
*/
|
|
164
258
|
get(): BrowserSettings {
|
|
165
259
|
let mtimeMs = -1
|
|
166
|
-
let text: string | undefined
|
|
167
260
|
try {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
261
|
+
// stat directly: existsSync costs about thirteen times what the stat does, and the
|
|
262
|
+
// stat already throws when the file is missing — which the catch below handles.
|
|
263
|
+
mtimeMs = statSync(this.file).mtimeMs
|
|
172
264
|
} catch {
|
|
173
265
|
// Unreadable file: fall through to defaults below.
|
|
174
266
|
mtimeMs = -1
|
|
175
|
-
text = undefined
|
|
176
267
|
}
|
|
268
|
+
// Return the cache BEFORE reading. The read used to happen first "just in case", which
|
|
269
|
+
// made the cache pointless: every call paid for a synchronous read of the whole file and
|
|
270
|
+
// then discarded it. This is on the path of every tool call that consults a setting, so
|
|
271
|
+
// it was a stat plus a read per call where a single stat will do.
|
|
177
272
|
if (this.cached !== undefined && mtimeMs === this.cachedMtimeMs) return this.cached
|
|
273
|
+
|
|
274
|
+
let text: string | undefined
|
|
275
|
+
if (mtimeMs !== -1) {
|
|
276
|
+
try {
|
|
277
|
+
text = readFileSync(this.file, 'utf8')
|
|
278
|
+
} catch {
|
|
279
|
+
// It was there a moment ago (the stat above) and cannot be read now: permissions, a
|
|
280
|
+
// lock, a directory in the way. That is not "no document" — the file is not absent, so
|
|
281
|
+
// what it said is unknown, and unknown resolves to the refusing document.
|
|
282
|
+
this.cached = SETTINGS_WHEN_UNREADABLE
|
|
283
|
+
this.cachedMtimeMs = mtimeMs
|
|
284
|
+
return this.cached
|
|
285
|
+
}
|
|
286
|
+
}
|
|
178
287
|
try {
|
|
179
288
|
// A leading BOM is what ordinary Windows editors (Notepad, PowerShell's
|
|
180
289
|
// `Set-Content -Encoding utf8`) leave behind, and `JSON.parse` rejects it.
|
|
@@ -184,8 +293,12 @@ export class SettingsStore {
|
|
|
184
293
|
const body = text?.replace(/^\uFEFF/, '')
|
|
185
294
|
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
|
|
186
295
|
} catch {
|
|
187
|
-
//
|
|
188
|
-
|
|
296
|
+
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
297
|
+
// first is a corrupted or half-written file whose contents were the operator's decisions,
|
|
298
|
+
// the second is a first run. Resolving both to the defaults meant a kill during the write,
|
|
299
|
+
// or one stray character, silently reopened the capabilities that had been switched off —
|
|
300
|
+
// so this side refuses them instead (see SETTINGS_WHEN_UNREADABLE).
|
|
301
|
+
this.cached = SETTINGS_WHEN_UNREADABLE
|
|
189
302
|
}
|
|
190
303
|
this.cachedMtimeMs = mtimeMs
|
|
191
304
|
return this.cached
|
|
@@ -201,7 +314,7 @@ export class SettingsStore {
|
|
|
201
314
|
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
|
|
202
315
|
try {
|
|
203
316
|
mkdirSync(dirname(this.file), { recursive: true })
|
|
204
|
-
|
|
317
|
+
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
|
205
318
|
} catch (error) {
|
|
206
319
|
// Do NOT publish the new value as cached, and do not pretend the file changed: a
|
|
207
320
|
// reader compares the file's mtime against this stamp, so stamping "now" on a failed
|
|
@@ -218,6 +331,42 @@ export class SettingsStore {
|
|
|
218
331
|
}
|
|
219
332
|
}
|
|
220
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Replace a file's contents atomically: write a sibling temporary file, then rename it over
|
|
336
|
+
* the target.
|
|
337
|
+
*
|
|
338
|
+
* `writeFileSync(target, …)` truncates and rewrites in place, so a crash, a full disk or an
|
|
339
|
+
* antivirus lock in the middle leaves HALF a JSON document. That used to be silent: the reader
|
|
340
|
+
* resolved an unparseable file to the defaults, so a torn write flipped `credentials.allowRead`
|
|
341
|
+
* and the action switches back ON, and the next successful update wrote those defaults to disk
|
|
342
|
+
* for good. The read side no longer does that (see {@link SETTINGS_WHEN_UNREADABLE}) — but the
|
|
343
|
+
* write side still has to stop producing torn files at all, because half a document is not
|
|
344
|
+
* something this plugin should ever leave behind.
|
|
345
|
+
*
|
|
346
|
+
* The rename is the commit point: a reader sees either the old document or the new one,
|
|
347
|
+
* never a partial one. It stays in the same directory so the rename is atomic on Windows
|
|
348
|
+
* as well as POSIX (`renameSync` maps to MoveFileEx there: same-volume replaces are
|
|
349
|
+
* atomic, cross-volume ones are not supported at all).
|
|
350
|
+
*
|
|
351
|
+
* The temporary is written with a unique suffix rather than a fixed `.tmp`, so two writers
|
|
352
|
+
* (the settings panel and a second DSH process) cannot rename each other's half-written
|
|
353
|
+
* file into place. Shared with the history store, which had the same hole.
|
|
354
|
+
* @param file - the target path.
|
|
355
|
+
* @param contents - the complete new document.
|
|
356
|
+
*/
|
|
357
|
+
export function writeFileAtomic(file: string, contents: string): void {
|
|
358
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`
|
|
359
|
+
try {
|
|
360
|
+
writeFileSync(temporary, contents)
|
|
361
|
+
renameSync(temporary, file)
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// The rename is the commit point, so a failure before it leaves the target untouched —
|
|
364
|
+
// only the temporary has to go, and it must not be left behind as garbage.
|
|
365
|
+
try { rmSync(temporary, { force: true }) } catch { /* the temp is already the least of it */ }
|
|
366
|
+
throw error
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
221
370
|
/** One-level-deep merge over the settings sections (patch wins). */
|
|
222
371
|
function deepMerge(base: Record<string, unknown>, patch: unknown): Record<string, unknown> {
|
|
223
372
|
if (typeof patch !== 'object' || patch === null) return base
|