dsh-builtin-browser 0.4.6 → 0.4.8
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 +76 -0
- package/README.en.md +62 -491
- package/README.md +44 -470
- package/client.js +10 -10
- package/cordis.patch.yml +4 -4
- package/desktop-bridge/plugin-browser-bridge.js +1 -1
- package/docs/README.md +8 -4
- package/docs/architecture.md +82 -11
- package/docs/development.md +38 -0
- package/docs/limitations.md +49 -0
- package/docs/tool-reference.md +42 -42
- package/docs/user-guide.md +93 -37
- package/docs/why-browser.md +3 -3
- package/lib/browser/types.d.ts +2 -2
- package/lib/browser-electron/bridge-connection.d.ts +7 -1
- package/lib/browser-electron/bridge-connection.js +58 -3
- package/lib/browser-electron/desktop-bridge-host.d.ts +11 -0
- package/lib/browser-electron/desktop-bridge-host.js +21 -1
- package/lib/browser-electron/entry.d.ts +2 -2
- package/lib/browser-electron/entry.js +186 -103
- package/lib/browser-electron/history-store.d.ts +1 -1
- package/lib/browser-electron/history-store.js +1 -1
- package/lib/browser-electron/host-main.js +3 -3
- package/lib/browser-electron/provider.d.ts +32 -8
- package/lib/browser-electron/provider.js +97 -16
- package/lib/browser-electron/remote-host.js +1 -1
- package/lib/browser-electron/settings-store.d.ts +14 -6
- package/lib/browser-electron/settings-store.js +29 -9
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/tool-browser/element-fields.d.ts +4 -4
- package/lib/tool-browser/element-fields.js +6 -6
- package/lib/tool-browser/index.d.ts +6 -6
- package/lib/tool-browser/index.js +196 -139
- package/package.json +2 -2
- package/scripts/verify-host-compat.mjs +6 -6
- package/src/browser/types.ts +2 -2
- package/src/browser-electron/bridge-connection.ts +61 -3
- package/src/browser-electron/desktop-bridge-host.ts +22 -1
- package/src/browser-electron/entry.ts +101 -9
- package/src/browser-electron/history-store.ts +1 -1
- package/src/browser-electron/host-main.ts +3 -3
- package/src/browser-electron/provider.ts +135 -31
- package/src/browser-electron/remote-host.ts +1 -1
- package/src/browser-electron/settings-store.ts +32 -13
- package/src/index.ts +1 -1
- package/src/tool-browser/element-fields.ts +6 -6
- package/src/tool-browser/index.ts +197 -140
|
@@ -99,7 +99,7 @@ export function layoutSize(metrics) {
|
|
|
99
99
|
return { width: content.width, height: content.height, viewportHeight: viewport?.height ?? content.height };
|
|
100
100
|
}
|
|
101
101
|
/**
|
|
102
|
-
* Map CDP cookies onto the shape `
|
|
102
|
+
* Map CDP cookies onto the shape `dsh_browser_auth` exports.
|
|
103
103
|
*
|
|
104
104
|
* CDP reports a cookie as domain + path rather than a URL, and in seconds rather than
|
|
105
105
|
* milliseconds, so both are converted. Entries missing a name, value or domain are
|
|
@@ -352,7 +352,7 @@ export class ElectronBrowserProvider {
|
|
|
352
352
|
// session), carrying the label for the window title. Hosts without
|
|
353
353
|
// groupView keep the shared-window behavior.
|
|
354
354
|
this.host.groupView?.(handle, id, label);
|
|
355
|
-
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 });
|
|
355
|
+
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 });
|
|
356
356
|
return Promise.resolve(id);
|
|
357
357
|
}
|
|
358
358
|
/** Open a URL in the active tab (default) or a new tab. */
|
|
@@ -371,9 +371,16 @@ export class ElectronBrowserProvider {
|
|
|
371
371
|
const tab = s.tabs[i];
|
|
372
372
|
if (tab === undefined)
|
|
373
373
|
continue; // defensive: array can shift under concurrency
|
|
374
|
+
// A tab we know has never navigated is NOT probed: its view has committed no document, so
|
|
375
|
+
// the round trip can only answer nothing — and it is the round trip that costs. A tab that
|
|
376
|
+
// HAS navigated is re-read, because the page may have moved itself since; a read that fails
|
|
377
|
+
// keeps the last accepted URL rather than reporting the tab as blank.
|
|
378
|
+
const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url);
|
|
379
|
+
if (liveUrl !== '')
|
|
380
|
+
tab.url = liveUrl;
|
|
374
381
|
result.push({
|
|
375
382
|
id: tab.id,
|
|
376
|
-
url:
|
|
383
|
+
url: liveUrl,
|
|
377
384
|
active: i === s.activeIndex,
|
|
378
385
|
});
|
|
379
386
|
}
|
|
@@ -455,7 +462,8 @@ export class ElectronBrowserProvider {
|
|
|
455
462
|
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
456
463
|
async navigate(session, request, signal) {
|
|
457
464
|
const s = this.session(session);
|
|
458
|
-
const
|
|
465
|
+
const tab = this.activeTab(s);
|
|
466
|
+
const { handle } = tab;
|
|
459
467
|
const url = request.url;
|
|
460
468
|
try {
|
|
461
469
|
if (this.httpOnly) {
|
|
@@ -496,6 +504,11 @@ export class ElectronBrowserProvider {
|
|
|
496
504
|
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
497
505
|
signal?.throwIfAborted();
|
|
498
506
|
this.record(s, 'navigate', { url }, true);
|
|
507
|
+
// The tab now holds a committed document, which is what the blank short-circuit in
|
|
508
|
+
// `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
|
|
509
|
+
// navigation HAS committed, and a tab wrongly treated as blank would return empty results
|
|
510
|
+
// for a page that is really there.
|
|
511
|
+
tab.url = url;
|
|
499
512
|
await this.settleDocument(handle, before, signal);
|
|
500
513
|
this.recordVisit(s, handle);
|
|
501
514
|
this.showActive(s);
|
|
@@ -543,9 +556,9 @@ export class ElectronBrowserProvider {
|
|
|
543
556
|
/**
|
|
544
557
|
* Page.navigate resolves at navigation COMMIT, not at load: the new document
|
|
545
558
|
* is already current (so location.href and document.title are the new page's)
|
|
546
|
-
* while its DOM is still being parsed.
|
|
559
|
+
* while its DOM is still being parsed. dsh_browser_open snapshots immediately
|
|
547
560
|
* after navigating, which is why it could report the right title with zero
|
|
548
|
-
* interactive elements — and why a separate
|
|
561
|
+
* interactive elements — and why a separate dsh_browser_snapshot right after
|
|
549
562
|
* always found them.
|
|
550
563
|
*
|
|
551
564
|
* Wait — bounded, best-effort — for the new document to settle: readyState
|
|
@@ -653,7 +666,7 @@ export class ElectronBrowserProvider {
|
|
|
653
666
|
*
|
|
654
667
|
* A renderer only delivers key events to the view that holds web focus. A
|
|
655
668
|
* view that was created but never clicked holds none — and `Input` events
|
|
656
|
-
* injected over CDP do not grant it — so the FIRST `
|
|
669
|
+
* injected over CDP do not grant it — so the FIRST `dsh_browser_key` of a fresh
|
|
657
670
|
* session vanished inside the renderer while the command still answered
|
|
658
671
|
* success. Hosts that cannot focus a view (a test double, a different shell
|
|
659
672
|
* adapter) simply have no `focus`, and nothing changes for them.
|
|
@@ -663,7 +676,7 @@ export class ElectronBrowserProvider {
|
|
|
663
676
|
return;
|
|
664
677
|
try {
|
|
665
678
|
// Bounded, unlike the rest of this call: the child RPC has no timeout of its own,
|
|
666
|
-
// and this runs before typing, so an unresponsive child would hang
|
|
679
|
+
// and this runs before typing, so an unresponsive child would hang dsh_browser_key for
|
|
667
680
|
// good. A timeout is caught below exactly like any other focus failure, so the
|
|
668
681
|
// best-effort contract is unchanged.
|
|
669
682
|
await withTimeout(handle.focus(), 5_000, undefined, 'browser: focusing the view timed out');
|
|
@@ -808,6 +821,11 @@ export class ElectronBrowserProvider {
|
|
|
808
821
|
const s = this.session(session);
|
|
809
822
|
const tab = this.activeTab(s);
|
|
810
823
|
signal?.throwIfAborted();
|
|
824
|
+
// A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
|
|
825
|
+
// it — there is no document to commit — so the call would sit out its whole budget before
|
|
826
|
+
// saying anything. Answering the empty page immediately is both faster and more truthful.
|
|
827
|
+
if (tab.url === '')
|
|
828
|
+
return { url: '', elements: [], truncated: false };
|
|
811
829
|
const script = `(() => {
|
|
812
830
|
const cap = ${String(this.snapshotMaxElements)}
|
|
813
831
|
const url = location.href
|
|
@@ -880,7 +898,22 @@ export class ElectronBrowserProvider {
|
|
|
880
898
|
// Same hang guard as execute: a renderer that has not committed after
|
|
881
899
|
// navigate would otherwise block snapshot forever.
|
|
882
900
|
const timeoutMs = 30_000;
|
|
883
|
-
|
|
901
|
+
let result;
|
|
902
|
+
try {
|
|
903
|
+
result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: snapshot timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
904
|
+
}
|
|
905
|
+
catch (error) {
|
|
906
|
+
// A timeout leaves the wedged view in the session, so every later read would pay the same
|
|
907
|
+
// 30 seconds again — and `terminatePage` only interrupts the running script, it does not
|
|
908
|
+
// make the view readable. Replacing it is what turns a permanent loss into one bad call:
|
|
909
|
+
// the tab keeps the id the caller holds, and the next dsh_browser_open with the real URL
|
|
910
|
+
// repopulates it.
|
|
911
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
912
|
+
this.replaceTabWithBlank(s, tab);
|
|
913
|
+
throw new BrowserError('browser: snapshot timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.', 'BROWSER_SNAPSHOT_TIMEOUT', { cause: error });
|
|
914
|
+
}
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
884
917
|
if (!result.ok)
|
|
885
918
|
throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED');
|
|
886
919
|
const value = result.value;
|
|
@@ -898,6 +931,10 @@ export class ElectronBrowserProvider {
|
|
|
898
931
|
const s = this.session(session);
|
|
899
932
|
const tab = this.activeTab(s);
|
|
900
933
|
signal?.throwIfAborted();
|
|
934
|
+
// Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
|
|
935
|
+
// the tree cannot be read and the call would spend its whole budget discovering that.
|
|
936
|
+
if (tab.url === '')
|
|
937
|
+
return { url: '', title: undefined, count: 0, nodes: [], truncated: false };
|
|
901
938
|
const includeHidden = request.includeHidden === true;
|
|
902
939
|
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
903
940
|
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
@@ -1059,7 +1096,19 @@ export class ElectronBrowserProvider {
|
|
|
1059
1096
|
return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
|
|
1060
1097
|
})()`;
|
|
1061
1098
|
const timeoutMs = 30_000;
|
|
1062
|
-
|
|
1099
|
+
let result;
|
|
1100
|
+
try {
|
|
1101
|
+
result = await withTimeout(handleSendEvaluate(tab.handle, script), timeoutMs, signal, `browser: a11y timed out after ${timeoutMs}ms`, () => terminatePage(tab.handle));
|
|
1102
|
+
}
|
|
1103
|
+
catch (error) {
|
|
1104
|
+
// As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
|
|
1105
|
+
// charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
|
|
1106
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1107
|
+
this.replaceTabWithBlank(s, tab);
|
|
1108
|
+
throw new BrowserError('browser: a11y timed out; the stuck tab was reset to blank. Call dsh_browser_open with the real target URL before reading it again.', 'BROWSER_A11Y_TIMEOUT', { cause: error });
|
|
1109
|
+
}
|
|
1110
|
+
throw error;
|
|
1111
|
+
}
|
|
1063
1112
|
if (!result.ok)
|
|
1064
1113
|
throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED');
|
|
1065
1114
|
const value = result.value;
|
|
@@ -1468,7 +1517,7 @@ export class ElectronBrowserProvider {
|
|
|
1468
1517
|
placeholder: f.placeholder ?? null,
|
|
1469
1518
|
// A field with NO kind is a field with no kind FILTER: absent, not 'text'. Defaulting
|
|
1470
1519
|
// it to 'text' here made `if (spec.kind)` above always true, so every unspecified
|
|
1471
|
-
// field was narrowed to an <input> —
|
|
1520
|
+
// field was narrowed to an <input> — dsh_browser_fill on a <textarea> reported
|
|
1472
1521
|
// `no "text" control matched`, and a contenteditable could not match any kind at all.
|
|
1473
1522
|
// The value stays a string or null (never undefined): the in-page check tests truthiness,
|
|
1474
1523
|
// and JSON drops an undefined member entirely, which is the same thing here.
|
|
@@ -1603,7 +1652,7 @@ export class ElectronBrowserProvider {
|
|
|
1603
1652
|
el.dispatchEvent(new Event('change', { bubbles: true }))
|
|
1604
1653
|
out.push({ ok: true, method: 'select', target: describe(spec) })
|
|
1605
1654
|
} else if (type === 'file') {
|
|
1606
|
-
out.push({ ok: false, error: 'file inputs cannot be set from script; use
|
|
1655
|
+
out.push({ ok: false, error: 'file inputs cannot be set from script; use dsh_browser_download or ask the human', target: describe(spec) })
|
|
1607
1656
|
} else if (type === 'checkbox') {
|
|
1608
1657
|
const want = spec.value === true || spec.value === 'true' || spec.value === 'on'
|
|
1609
1658
|
if (el.checked !== want) el.click()
|
|
@@ -2031,7 +2080,7 @@ export class ElectronBrowserProvider {
|
|
|
2031
2080
|
* Refuse an action the settings have switched off.
|
|
2032
2081
|
*
|
|
2033
2082
|
* These switches belong to the OPERATOR. The settings document is written through the
|
|
2034
|
-
* plugin's own panel and no tool can reach it, so — unlike `
|
|
2083
|
+
* plugin's own panel and no tool can reach it, so — unlike `dsh_browser_restrict`, which the
|
|
2035
2084
|
* model owns and can lift at will — a refusal here stands for as long as the setting does.
|
|
2036
2085
|
* That is the point of them: a deployment can take page-script execution, downloads, or
|
|
2037
2086
|
* login-state writes off the table without relying on the model's cooperation.
|
|
@@ -2058,7 +2107,7 @@ export class ElectronBrowserProvider {
|
|
|
2058
2107
|
}
|
|
2059
2108
|
/**
|
|
2060
2109
|
* Admit a caller-supplied save path for a file the browser writes to disk.
|
|
2061
|
-
* ONE gate for both `
|
|
2110
|
+
* ONE gate for both `dsh_browser_download` and `dsh_browser_screenshot`: the path
|
|
2062
2111
|
* must be absolute, must resolve inside `downloadDir`, and must not already
|
|
2063
2112
|
* exist. Without it a prompt-injected agent could write — or silently
|
|
2064
2113
|
* replace — any file the DSH process can reach, which also escapes the file
|
|
@@ -2440,7 +2489,7 @@ export class ElectronBrowserProvider {
|
|
|
2440
2489
|
.catch(() => { });
|
|
2441
2490
|
}
|
|
2442
2491
|
/**
|
|
2443
|
-
* List persisted visits, newest first — what `
|
|
2492
|
+
* List persisted visits, newest first — what `dsh_browser_visited` reads back.
|
|
2444
2493
|
* @param options - result cap and an optional hostname filter.
|
|
2445
2494
|
* @returns the matching visits, or an empty list when history is disabled.
|
|
2446
2495
|
*/
|
|
@@ -2579,10 +2628,42 @@ export class ElectronBrowserProvider {
|
|
|
2579
2628
|
const handle = this.host.createView(s.label);
|
|
2580
2629
|
// Same window as the session's other tabs.
|
|
2581
2630
|
this.host.groupView?.(handle, s.id, s.label);
|
|
2582
|
-
s.tabs.push({ id: `tab:${randomUUID()}`, handle });
|
|
2631
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' });
|
|
2583
2632
|
s.activeIndex = s.tabs.length - 1;
|
|
2584
2633
|
this.showActive(s);
|
|
2585
2634
|
}
|
|
2635
|
+
/**
|
|
2636
|
+
* Swap a wedged view for a fresh blank one, keeping the tab's public id.
|
|
2637
|
+
*
|
|
2638
|
+
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
2639
|
+
* whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
|
|
2640
|
+
* callers hold stays valid, and the next `dsh_browser_open` with the real URL re-populates it.
|
|
2641
|
+
*
|
|
2642
|
+
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
2643
|
+
* session holding a handle that is already gone.
|
|
2644
|
+
* @param s - the session owning the tab.
|
|
2645
|
+
* @param tab - the tab to reset.
|
|
2646
|
+
*/
|
|
2647
|
+
replaceTabWithBlank(s, tab) {
|
|
2648
|
+
const index = s.tabs.indexOf(tab);
|
|
2649
|
+
if (index < 0)
|
|
2650
|
+
return;
|
|
2651
|
+
let handle;
|
|
2652
|
+
try {
|
|
2653
|
+
handle = this.host.createView(s.label);
|
|
2654
|
+
this.host.groupView?.(handle, s.id, s.label);
|
|
2655
|
+
}
|
|
2656
|
+
catch {
|
|
2657
|
+
// Creating the replacement failed: leaving the wedged tab in place is worse than nothing
|
|
2658
|
+
// only for the caller, who is about to be told the tab timed out either way.
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
const wedged = tab.handle;
|
|
2662
|
+
s.tabs[index] = { id: tab.id, handle, url: '' };
|
|
2663
|
+
if (index === s.activeIndex)
|
|
2664
|
+
this.showActive(s);
|
|
2665
|
+
this.host.destroyView(wedged);
|
|
2666
|
+
}
|
|
2586
2667
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
2587
2668
|
tabByViewId(s, viewId) {
|
|
2588
2669
|
return s.tabs.find(tab => tab.handle.id === viewId);
|
|
@@ -1275,7 +1275,7 @@ class DeferredRemoteView {
|
|
|
1275
1275
|
* with "browser host is not running". Instead: drop the cache, rebuild the
|
|
1276
1276
|
* view against a freshly spawned child, and retry the operation exactly
|
|
1277
1277
|
* once — so sessions that survive a host death self-heal on their FIRST
|
|
1278
|
-
* call after it, without a manual
|
|
1278
|
+
* call after it, without a manual dsh_browser_reset_session. Page-level errors
|
|
1279
1279
|
* are never retried (the view itself is fine).
|
|
1280
1280
|
*/
|
|
1281
1281
|
async withRecovery(op) {
|
|
@@ -49,15 +49,15 @@ export interface BrowserSettings {
|
|
|
49
49
|
*
|
|
50
50
|
* These are OPERATOR switches: the settings panel is not reachable from the model, so a
|
|
51
51
|
* value here refuses whatever the model asks for, and no tool can lift it. That is what
|
|
52
|
-
* separates them from `
|
|
52
|
+
* separates them from `dsh_browser_restrict`, which is the model's own soft guardrail. All
|
|
53
53
|
* three default to on, which is the documented out-of-the-box behaviour.
|
|
54
54
|
*/
|
|
55
55
|
readonly actions: {
|
|
56
|
-
/** Run arbitrary page scripts in the active tab (`
|
|
56
|
+
/** Run arbitrary page scripts in the active tab (`dsh_browser_execute`). */
|
|
57
57
|
readonly allowExecute: boolean;
|
|
58
|
-
/** Download a URL to disk (`
|
|
58
|
+
/** Download a URL to disk (`dsh_browser_download`). */
|
|
59
59
|
readonly allowDownload: boolean;
|
|
60
|
-
/** Write cookies — importing login state (`
|
|
60
|
+
/** Write cookies — importing login state (`dsh_browser_auth` restore). */
|
|
61
61
|
readonly allowCredentialWrite: boolean;
|
|
62
62
|
};
|
|
63
63
|
}
|
|
@@ -69,6 +69,13 @@ export declare const DEFAULT_SETTINGS: BrowserSettings;
|
|
|
69
69
|
* @returns the settings file path.
|
|
70
70
|
*/
|
|
71
71
|
export declare function settingsPath(): string;
|
|
72
|
+
/**
|
|
73
|
+
* The channels a settings document may name.
|
|
74
|
+
*
|
|
75
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
76
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
77
|
+
*/
|
|
78
|
+
export declare const BROWSER_CHANNELS: readonly ["chrome", "edge", "auto", "bundled", "sidebar"];
|
|
72
79
|
/**
|
|
73
80
|
* Resolve a raw settings document (already parsed, shape unknown) into the full
|
|
74
81
|
* document, field by field. Unknown keys are dropped: the file is not a place to
|
|
@@ -76,7 +83,7 @@ export declare function settingsPath(): string;
|
|
|
76
83
|
* @param raw - the parsed file contents.
|
|
77
84
|
* @returns the resolved settings.
|
|
78
85
|
*/
|
|
79
|
-
export declare function resolveSettings(raw: unknown): BrowserSettings;
|
|
86
|
+
export declare function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings;
|
|
80
87
|
/**
|
|
81
88
|
* File-backed settings with an in-memory snapshot. Reads re-stat the file so an
|
|
82
89
|
* edit from another process (or the settings panel writing through a second
|
|
@@ -85,13 +92,14 @@ export declare function resolveSettings(raw: unknown): BrowserSettings;
|
|
|
85
92
|
*/
|
|
86
93
|
export declare class SettingsStore {
|
|
87
94
|
private readonly file;
|
|
95
|
+
private readonly log?;
|
|
88
96
|
private cached;
|
|
89
97
|
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
90
98
|
private cachedMtimeMs;
|
|
91
99
|
/**
|
|
92
100
|
* @param file - settings file path (defaults beside the browser profile).
|
|
93
101
|
*/
|
|
94
|
-
constructor(file?: string);
|
|
102
|
+
constructor(file?: string, log?: ((message: string) => void) | undefined);
|
|
95
103
|
/** Path this store reads and writes. */
|
|
96
104
|
path(): string;
|
|
97
105
|
/**
|
|
@@ -68,16 +68,34 @@ const SETTINGS_WHEN_UNREADABLE = {
|
|
|
68
68
|
credentials: { allowRead: false },
|
|
69
69
|
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
70
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* The channels a settings document may name.
|
|
73
|
+
*
|
|
74
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
75
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
76
|
+
*/
|
|
77
|
+
export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'];
|
|
71
78
|
/**
|
|
72
79
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
73
80
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
81
|
+
*
|
|
82
|
+
* Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
|
|
83
|
+
* the same to whoever typed it, which is why every discarded value is reported.
|
|
74
84
|
* @param value - the raw value from the settings document.
|
|
85
|
+
* @param warn - where to report a discarded value, when the caller has somewhere to put it.
|
|
75
86
|
* @returns a valid channel.
|
|
76
87
|
*/
|
|
77
|
-
function resolveChannel(value) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
function resolveChannel(value, warn) {
|
|
89
|
+
// Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
|
|
90
|
+
// the default without a word.
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
return DEFAULT_SETTINGS.browser.channel;
|
|
93
|
+
if (BROWSER_CHANNELS.includes(value))
|
|
94
|
+
return value;
|
|
95
|
+
warn?.(`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
|
|
96
|
+
+ `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
|
|
97
|
+
+ `${BROWSER_CHANNELS.join(', ')}.`);
|
|
98
|
+
return DEFAULT_SETTINGS.browser.channel;
|
|
81
99
|
}
|
|
82
100
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
83
101
|
function count(value, fallback) {
|
|
@@ -119,7 +137,7 @@ function section(source, key) {
|
|
|
119
137
|
* @param raw - the parsed file contents.
|
|
120
138
|
* @returns the resolved settings.
|
|
121
139
|
*/
|
|
122
|
-
export function resolveSettings(raw) {
|
|
140
|
+
export function resolveSettings(raw, warn) {
|
|
123
141
|
const source = typeof raw === 'object' && raw !== null ? raw : {};
|
|
124
142
|
const history = section(source, 'history');
|
|
125
143
|
const cookies = section(source, 'cookies');
|
|
@@ -146,7 +164,7 @@ export function resolveSettings(raw) {
|
|
|
146
164
|
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
147
165
|
},
|
|
148
166
|
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
149
|
-
browser: { channel: resolveChannel(browser?.channel) },
|
|
167
|
+
browser: { channel: resolveChannel(browser?.channel, warn) },
|
|
150
168
|
credentials: {
|
|
151
169
|
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
152
170
|
// is refused here for free: the section itself is the "present but wrong" value.
|
|
@@ -175,14 +193,16 @@ export function resolveSettings(raw) {
|
|
|
175
193
|
*/
|
|
176
194
|
export class SettingsStore {
|
|
177
195
|
file;
|
|
196
|
+
log;
|
|
178
197
|
cached;
|
|
179
198
|
/** Last observed mtime, used to invalidate the cache on external edits. */
|
|
180
199
|
cachedMtimeMs = -1;
|
|
181
200
|
/**
|
|
182
201
|
* @param file - settings file path (defaults beside the browser profile).
|
|
183
202
|
*/
|
|
184
|
-
constructor(file = settingsPath()) {
|
|
203
|
+
constructor(file = settingsPath(), log) {
|
|
185
204
|
this.file = file;
|
|
205
|
+
this.log = log;
|
|
186
206
|
}
|
|
187
207
|
/** Path this store reads and writes. */
|
|
188
208
|
path() {
|
|
@@ -230,7 +250,7 @@ export class SettingsStore {
|
|
|
230
250
|
// value in it: the document looked correct, and the plugin quietly ran on
|
|
231
251
|
// defaults — including switches the user had just turned off.
|
|
232
252
|
const body = text?.replace(/^\uFEFF/, '');
|
|
233
|
-
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body));
|
|
253
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m));
|
|
234
254
|
}
|
|
235
255
|
catch {
|
|
236
256
|
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
@@ -250,7 +270,7 @@ export class SettingsStore {
|
|
|
250
270
|
*/
|
|
251
271
|
update(patch) {
|
|
252
272
|
const current = this.get();
|
|
253
|
-
const merged = resolveSettings(deepMerge(current, patch));
|
|
273
|
+
const merged = resolveSettings(deepMerge(current, patch), m => this.log?.(m));
|
|
254
274
|
try {
|
|
255
275
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
256
276
|
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`);
|
package/lib/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
-
* - `dsh-builtin-browser/tool-browser` -> the model-facing
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
|
|
7
7
|
* The capability itself is composed by those rows; this root entry exists for
|
|
8
8
|
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
9
|
* package's browser half through a loader row whose specifier resolves to the
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* pieces. The cordis.patch.yml rows reference subpath exports:
|
|
4
4
|
* - `dsh-builtin-browser/browser` -> the ctx.browser seam (Service)
|
|
5
5
|
* - `dsh-builtin-browser/browser-electron` -> the Electron CDP provider
|
|
6
|
-
* - `dsh-builtin-browser/tool-browser` -> the model-facing
|
|
6
|
+
* - `dsh-builtin-browser/tool-browser` -> the model-facing dsh_browser_* tools
|
|
7
7
|
* The capability itself is composed by those rows; this root entry exists for
|
|
8
8
|
* programmatic imports AND because the host's client-module scan identifies a
|
|
9
9
|
* package's browser half through a loader row whose specifier resolves to the
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* and the tool's `output.schema` declared the properties. Nothing connected them, and the
|
|
9
9
|
* result is this project's most repeatable bug:
|
|
10
10
|
*
|
|
11
|
-
* - `
|
|
11
|
+
* - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
|
|
12
12
|
* printed by the renderer, and dropped by the projection. 134 tests stayed green.
|
|
13
|
-
* - `
|
|
13
|
+
* - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
|
|
14
14
|
* schema, and dropped by the projection. 172 tests stayed green. It was found a round
|
|
15
15
|
* later, by reading the three places that agree and noticing the fourth did not.
|
|
16
16
|
*
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* projection forgets is silently dropped. One table cannot get either wrong on its own.
|
|
25
25
|
*/
|
|
26
26
|
/**
|
|
27
|
-
* The `elements` item schema for
|
|
27
|
+
* The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
|
|
28
28
|
*
|
|
29
29
|
* Written as a literal rather than generated, because `defineTool` validates the output schema
|
|
30
30
|
* against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
|
|
@@ -65,7 +65,7 @@ export declare const ELEMENT_ITEM_SCHEMA: {
|
|
|
65
65
|
};
|
|
66
66
|
};
|
|
67
67
|
/**
|
|
68
|
-
* The `nodes` item schema for
|
|
68
|
+
* The `nodes` item schema for dsh_browser_a11y.
|
|
69
69
|
*
|
|
70
70
|
* Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
|
|
71
71
|
* test.
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* and the tool's `output.schema` declared the properties. Nothing connected them, and the
|
|
9
9
|
* result is this project's most repeatable bug:
|
|
10
10
|
*
|
|
11
|
-
* - `
|
|
11
|
+
* - `dsh_browser_a11y`'s `selector` — computed by the provider, declared by the schema,
|
|
12
12
|
* printed by the renderer, and dropped by the projection. 134 tests stayed green.
|
|
13
|
-
* - `
|
|
13
|
+
* - `dsh_browser_screenshot`'s `width`/`height` — measured by the provider, declared by the
|
|
14
14
|
* schema, and dropped by the projection. 172 tests stayed green. It was found a round
|
|
15
15
|
* later, by reading the three places that agree and noticing the fourth did not.
|
|
16
16
|
*
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* projection forgets is silently dropped. One table cannot get either wrong on its own.
|
|
25
25
|
*/
|
|
26
26
|
/**
|
|
27
|
-
* The fields of one entry in `
|
|
27
|
+
* The fields of one entry in `dsh_browser_snapshot` / `dsh_browser_open`'s `elements`.
|
|
28
28
|
*
|
|
29
29
|
* `selector` is the reason this file exists; see the header. It was added to the schema and
|
|
30
30
|
* the projection separately, and only one of them landed.
|
|
@@ -39,7 +39,7 @@ const ELEMENT_FIELDS = {
|
|
|
39
39
|
frame: { schema: { type: 'boolean' } },
|
|
40
40
|
};
|
|
41
41
|
/**
|
|
42
|
-
* The fields of one entry in `
|
|
42
|
+
* The fields of one entry in `dsh_browser_a11y`'s `nodes`.
|
|
43
43
|
*
|
|
44
44
|
* Different shape from an element on purpose: a role and an accessible name carry meaning a
|
|
45
45
|
* tag and a label string do not, and `depth` is what renders the tree.
|
|
@@ -80,7 +80,7 @@ function project(fields, source) {
|
|
|
80
80
|
return out;
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* The `elements` item schema for
|
|
83
|
+
* The `elements` item schema for dsh_browser_snapshot and dsh_browser_open.
|
|
84
84
|
*
|
|
85
85
|
* Written as a literal rather than generated, because `defineTool` validates the output schema
|
|
86
86
|
* against its own `ValueSchemaSpec` and a generated `Record<string, …>` is too wide to satisfy
|
|
@@ -102,7 +102,7 @@ export const ELEMENT_ITEM_SCHEMA = {
|
|
|
102
102
|
},
|
|
103
103
|
};
|
|
104
104
|
/**
|
|
105
|
-
* The `nodes` item schema for
|
|
105
|
+
* The `nodes` item schema for dsh_browser_a11y.
|
|
106
106
|
*
|
|
107
107
|
* Same reasoning as {@link ELEMENT_ITEM_SCHEMA}: literal for the type checker, table-backed by
|
|
108
108
|
* test.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model-facing browser tools over `ctx.browser`: `
|
|
3
|
-
* `
|
|
4
|
-
* `
|
|
5
|
-
* `
|
|
2
|
+
* Model-facing browser tools over `ctx.browser`: `dsh_browser_open`,
|
|
3
|
+
* `dsh_browser_snapshot`, `dsh_browser_execute`, `dsh_browser_content`,
|
|
4
|
+
* `dsh_browser_screenshot`, and tab management (`dsh_browser_list_tabs`,
|
|
5
|
+
* `dsh_browser_switch_tab`, `dsh_browser_close_tab`, `dsh_browser_reset`).
|
|
6
6
|
*
|
|
7
7
|
* The tool layer owns only the model-facing schema, argument validation, and
|
|
8
8
|
* result formatting — never provider selection or page driving, which belong
|
|
9
9
|
* to the seam. Session lifecycle is owned here at the plugin level: each
|
|
10
10
|
* calling task (a DSH session) gets its own browser session — the first
|
|
11
|
-
* `
|
|
11
|
+
* `dsh_browser_open` (or any tool when no session exists) opens it, and later
|
|
12
12
|
* tools in the same task reuse it. Concurrent tasks therefore never fight
|
|
13
13
|
* over tabs, history, or navigation state.
|
|
14
14
|
* @module dsh-browser/tool-browser
|
|
@@ -38,7 +38,7 @@ export interface Config {
|
|
|
38
38
|
* Tools that only observe, plus the ones that undo a restriction.
|
|
39
39
|
*
|
|
40
40
|
* Never subject to an allow-list: restricting what the agent may *do* must not also
|
|
41
|
-
* blind it or trap it, and the tool descriptions promise as much.
|
|
41
|
+
* blind it or trap it, and the tool descriptions promise as much. dsh_browser_restrict
|
|
42
42
|
* is in here so a task that restricted everything can still lift it.
|
|
43
43
|
*/
|
|
44
44
|
export declare const READ_ONLY_TOOLS: ReadonlySet<string>;
|