dsh-builtin-browser 0.4.6 → 0.4.7
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 +48 -0
- 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.js +185 -102
- package/lib/browser-electron/provider.d.ts +24 -0
- package/lib/browser-electron/provider.js +87 -6
- package/lib/browser-electron/settings-store.d.ts +10 -2
- package/lib/browser-electron/settings-store.js +29 -9
- package/lib/tool-browser/index.js +36 -1
- package/package.json +1 -1
- 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 +99 -7
- package/src/browser-electron/provider.ts +124 -20
- package/src/browser-electron/settings-store.ts +28 -9
- package/src/tool-browser/index.ts +34 -1
|
@@ -217,6 +217,17 @@ export interface ElectronBrowserViewHost {
|
|
|
217
217
|
* which is only correct from a teardown that is itself global.
|
|
218
218
|
*/
|
|
219
219
|
releasePage?(owner?: string): Promise<void>
|
|
220
|
+
/**
|
|
221
|
+
* Report that this carrier can no longer serve anything, so its owner may replace it.
|
|
222
|
+
*
|
|
223
|
+
* A carrier that borrows an outside process can lose it: the desktop shell exits, and every call
|
|
224
|
+
* after that would dial a port nobody owns. Without this signal the swap happens only at
|
|
225
|
+
* startup, so the choice made then is final for the process's life — which is what a user
|
|
226
|
+
* experiences as "the browser stopped working until I restart DSH". Optional: a carrier that
|
|
227
|
+
* owns its own lifetime has nothing to report.
|
|
228
|
+
* @param listener - called once, when the carrier concludes it is gone.
|
|
229
|
+
*/
|
|
230
|
+
onLost?(listener: () => void): void
|
|
220
231
|
/**
|
|
221
232
|
* Fold the carrier's presentation away while keeping the page alive.
|
|
222
233
|
*
|
|
@@ -312,10 +323,21 @@ export interface ElectronViewHandle {
|
|
|
312
323
|
capture?(opts?: ScreenshotOptions): Promise<{ base64: string; mime: string; width?: number; height?: number }>
|
|
313
324
|
}
|
|
314
325
|
|
|
315
|
-
/**
|
|
326
|
+
/**
|
|
327
|
+
* One tab inside a session: its view, a stable id, and the last URL this provider accepted.
|
|
328
|
+
*
|
|
329
|
+
* `url` does two jobs. It separates "never navigated" from "navigated, but the URL could not be
|
|
330
|
+
* read" — the two used to collapse into the same empty string — and it lets `listTabs` skip
|
|
331
|
+
* probing a tab it already knows is blank, where a probe is a round trip on an uncommitted view
|
|
332
|
+
* that can only answer nothing.
|
|
333
|
+
*
|
|
334
|
+
* `handle` is deliberately NOT readonly: a view that has wedged is replaced in place, and the tab
|
|
335
|
+
* keeps its public id across that replacement (see `replaceTabWithBlank`).
|
|
336
|
+
*/
|
|
316
337
|
interface Tab {
|
|
317
338
|
readonly id: string
|
|
318
|
-
|
|
339
|
+
handle: ElectronViewHandle
|
|
340
|
+
url: string
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
/** One live browser session: an ordered list of tabs, one active. */
|
|
@@ -717,7 +739,7 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
717
739
|
// session), carrying the label for the window title. Hosts without
|
|
718
740
|
// groupView keep the shared-window behavior.
|
|
719
741
|
this.host.groupView?.(handle, id, label)
|
|
720
|
-
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
742
|
+
this.sessions.set(id, { id, label, tabs: [{ id: `tab:${randomUUID()}`, handle, url: '' }], activeIndex: 0, history: [], nextSeq: 1 })
|
|
721
743
|
return Promise.resolve(id)
|
|
722
744
|
}
|
|
723
745
|
|
|
@@ -737,9 +759,15 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
737
759
|
for (let i = 0; i < s.tabs.length; i++) {
|
|
738
760
|
const tab = s.tabs[i]
|
|
739
761
|
if (tab === undefined) continue // defensive: array can shift under concurrency
|
|
762
|
+
// A tab we know has never navigated is NOT probed: its view has committed no document, so
|
|
763
|
+
// the round trip can only answer nothing — and it is the round trip that costs. A tab that
|
|
764
|
+
// HAS navigated is re-read, because the page may have moved itself since; a read that fails
|
|
765
|
+
// keeps the last accepted URL rather than reporting the tab as blank.
|
|
766
|
+
const liveUrl = tab.url === '' ? '' : await this.currentUrl(tab.handle).catch(() => tab.url)
|
|
767
|
+
if (liveUrl !== '') tab.url = liveUrl
|
|
740
768
|
result.push({
|
|
741
769
|
id: tab.id,
|
|
742
|
-
url:
|
|
770
|
+
url: liveUrl,
|
|
743
771
|
active: i === s.activeIndex,
|
|
744
772
|
})
|
|
745
773
|
}
|
|
@@ -825,7 +853,8 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
825
853
|
/** Navigate the active tab's view to a URL, honoring HTTP(S)-only admission. */
|
|
826
854
|
async navigate(session: BrowserSessionId, request: { readonly url: string }, signal?: AbortSignal): Promise<void> {
|
|
827
855
|
const s = this.session(session)
|
|
828
|
-
const
|
|
856
|
+
const tab = this.activeTab(s)
|
|
857
|
+
const { handle } = tab
|
|
829
858
|
const url = request.url
|
|
830
859
|
try {
|
|
831
860
|
if (this.httpOnly) {
|
|
@@ -870,6 +899,11 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
870
899
|
// the visit written to the persistent history for a navigation nobody awaits any more.
|
|
871
900
|
signal?.throwIfAborted()
|
|
872
901
|
this.record(s, 'navigate', { url }, true)
|
|
902
|
+
// The tab now holds a committed document, which is what the blank short-circuit in
|
|
903
|
+
// `snapshot`/`a11y` keys on. Recorded here rather than after the settle below: the
|
|
904
|
+
// navigation HAS committed, and a tab wrongly treated as blank would return empty results
|
|
905
|
+
// for a page that is really there.
|
|
906
|
+
tab.url = url
|
|
873
907
|
await this.settleDocument(handle, before, signal)
|
|
874
908
|
this.recordVisit(s, handle)
|
|
875
909
|
this.showActive(s)
|
|
@@ -1205,6 +1239,10 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1205
1239
|
const s = this.session(session)
|
|
1206
1240
|
const tab = this.activeTab(s)
|
|
1207
1241
|
signal?.throwIfAborted()
|
|
1242
|
+
// A tab that has never navigated is a blank, uncommitted view. The collector cannot answer in
|
|
1243
|
+
// it — there is no document to commit — so the call would sit out its whole budget before
|
|
1244
|
+
// saying anything. Answering the empty page immediately is both faster and more truthful.
|
|
1245
|
+
if (tab.url === '') return { url: '', elements: [], truncated: false }
|
|
1208
1246
|
const script = `(() => {
|
|
1209
1247
|
const cap = ${String(this.snapshotMaxElements)}
|
|
1210
1248
|
const url = location.href
|
|
@@ -1277,13 +1315,31 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1277
1315
|
// Same hang guard as execute: a renderer that has not committed after
|
|
1278
1316
|
// navigate would otherwise block snapshot forever.
|
|
1279
1317
|
const timeoutMs = 30_000
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1318
|
+
let result: { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1319
|
+
try {
|
|
1320
|
+
result = await withTimeout(
|
|
1321
|
+
handleSendEvaluate(tab.handle, script),
|
|
1322
|
+
timeoutMs,
|
|
1323
|
+
signal,
|
|
1324
|
+
`browser: snapshot timed out after ${timeoutMs}ms`,
|
|
1325
|
+
() => terminatePage(tab.handle),
|
|
1326
|
+
) as { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
// A timeout leaves the wedged view in the session, so every later read would pay the same
|
|
1329
|
+
// 30 seconds again — and `terminatePage` only interrupts the running script, it does not
|
|
1330
|
+
// make the view readable. Replacing it is what turns a permanent loss into one bad call:
|
|
1331
|
+
// the tab keeps the id the caller holds, and the next browser_open with the real URL
|
|
1332
|
+
// repopulates it.
|
|
1333
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1334
|
+
this.replaceTabWithBlank(s, tab)
|
|
1335
|
+
throw new BrowserError(
|
|
1336
|
+
'browser: snapshot timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.',
|
|
1337
|
+
'BROWSER_SNAPSHOT_TIMEOUT',
|
|
1338
|
+
{ cause: error },
|
|
1339
|
+
)
|
|
1340
|
+
}
|
|
1341
|
+
throw error
|
|
1342
|
+
}
|
|
1287
1343
|
if (!result.ok) throw new BrowserError(`browser: snapshot evaluation failed: ${result.exception}`, 'BROWSER_SNAPSHOT_FAILED')
|
|
1288
1344
|
const value = result.value as BrowserSnapshotResult
|
|
1289
1345
|
return value
|
|
@@ -1301,6 +1357,9 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1301
1357
|
const s = this.session(session)
|
|
1302
1358
|
const tab = this.activeTab(s)
|
|
1303
1359
|
signal?.throwIfAborted()
|
|
1360
|
+
// Same short-circuit as `snapshot`: a tab that never navigated has no committed document, so
|
|
1361
|
+
// the tree cannot be read and the call would spend its whole budget discovering that.
|
|
1362
|
+
if (tab.url === '') return { url: '', title: undefined, count: 0, nodes: [], truncated: false }
|
|
1304
1363
|
const includeHidden = request.includeHidden === true
|
|
1305
1364
|
// 150 rather than 500. The cap fills on any real page, and 500 nodes measured 38,953
|
|
1306
1365
|
// characters — 10k-13k tokens for a single call. A caller that needs more asks for it.
|
|
@@ -1462,13 +1521,28 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
1462
1521
|
return { url: location.href, title: document.title || undefined, count: out.length, nodes: out, truncated: out.length >= maxNodes }
|
|
1463
1522
|
})()`
|
|
1464
1523
|
const timeoutMs = 30_000
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1524
|
+
let result: { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1525
|
+
try {
|
|
1526
|
+
result = await withTimeout(
|
|
1527
|
+
handleSendEvaluate(tab.handle, script),
|
|
1528
|
+
timeoutMs,
|
|
1529
|
+
signal,
|
|
1530
|
+
`browser: a11y timed out after ${timeoutMs}ms`,
|
|
1531
|
+
() => terminatePage(tab.handle),
|
|
1532
|
+
) as { ok: true; value: unknown } | { ok: false; exception: string }
|
|
1533
|
+
} catch (error) {
|
|
1534
|
+
// As in `snapshot`: a timeout means the view is wedged, and leaving it in the session would
|
|
1535
|
+
// charge every later read the same 30 seconds. Replaced in place, so the tab id survives.
|
|
1536
|
+
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
1537
|
+
this.replaceTabWithBlank(s, tab)
|
|
1538
|
+
throw new BrowserError(
|
|
1539
|
+
'browser: a11y timed out; the stuck tab was reset to blank. Call browser_open with the real target URL before reading it again.',
|
|
1540
|
+
'BROWSER_A11Y_TIMEOUT',
|
|
1541
|
+
{ cause: error },
|
|
1542
|
+
)
|
|
1543
|
+
}
|
|
1544
|
+
throw error
|
|
1545
|
+
}
|
|
1472
1546
|
if (!result.ok) throw new BrowserError(`browser: a11y evaluation failed: ${result.exception}`, 'BROWSER_A11Y_FAILED')
|
|
1473
1547
|
const value = result.value as BrowserA11yResult
|
|
1474
1548
|
this.record(s, 'a11y', { includeHidden, maxNodes }, true, { result: `${value.count} nodes` })
|
|
@@ -3116,11 +3190,41 @@ export class ElectronBrowserProvider implements BrowserProvider {
|
|
|
3116
3190
|
const handle = this.host.createView(s.label)
|
|
3117
3191
|
// Same window as the session's other tabs.
|
|
3118
3192
|
this.host.groupView?.(handle, s.id, s.label)
|
|
3119
|
-
s.tabs.push({ id: `tab:${randomUUID()}`, handle })
|
|
3193
|
+
s.tabs.push({ id: `tab:${randomUUID()}`, handle, url: '' })
|
|
3120
3194
|
s.activeIndex = s.tabs.length - 1
|
|
3121
3195
|
this.showActive(s)
|
|
3122
3196
|
}
|
|
3123
3197
|
|
|
3198
|
+
/**
|
|
3199
|
+
* Swap a wedged view for a fresh blank one, keeping the tab's public id.
|
|
3200
|
+
*
|
|
3201
|
+
* A view whose renderer never commits cannot be read again: every evaluate into it runs out its
|
|
3202
|
+
* whole budget. Replacing it is what turns that from permanent into one bad call — the tab id
|
|
3203
|
+
* callers hold stays valid, and the next `browser_open` with the real URL re-populates it.
|
|
3204
|
+
*
|
|
3205
|
+
* The old view is destroyed AFTER the new one is in place, so a failure here cannot leave the
|
|
3206
|
+
* session holding a handle that is already gone.
|
|
3207
|
+
* @param s - the session owning the tab.
|
|
3208
|
+
* @param tab - the tab to reset.
|
|
3209
|
+
*/
|
|
3210
|
+
private replaceTabWithBlank(s: Session, tab: Tab): void {
|
|
3211
|
+
const index = s.tabs.indexOf(tab)
|
|
3212
|
+
if (index < 0) return
|
|
3213
|
+
let handle: ElectronViewHandle
|
|
3214
|
+
try {
|
|
3215
|
+
handle = this.host.createView(s.label)
|
|
3216
|
+
this.host.groupView?.(handle, s.id, s.label)
|
|
3217
|
+
} catch {
|
|
3218
|
+
// Creating the replacement failed: leaving the wedged tab in place is worse than nothing
|
|
3219
|
+
// only for the caller, who is about to be told the tab timed out either way.
|
|
3220
|
+
return
|
|
3221
|
+
}
|
|
3222
|
+
const wedged = tab.handle
|
|
3223
|
+
s.tabs[index] = { id: tab.id, handle, url: '' }
|
|
3224
|
+
if (index === s.activeIndex) this.showActive(s)
|
|
3225
|
+
this.host.destroyView(wedged)
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3124
3228
|
/** Find a session's tab by its backing view id (toolbar actions carry view ids). */
|
|
3125
3229
|
private tabByViewId(s: Session, viewId: string): Tab | undefined {
|
|
3126
3230
|
return s.tabs.find(tab => tab.handle.id === viewId)
|
|
@@ -129,16 +129,35 @@ const SETTINGS_WHEN_UNREADABLE: BrowserSettings = {
|
|
|
129
129
|
actions: { allowExecute: false, allowDownload: false, allowCredentialWrite: false },
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The channels a settings document may name.
|
|
134
|
+
*
|
|
135
|
+
* Exported so the log line and the error message can quote the same set the parser accepts: a
|
|
136
|
+
* message that lists a value the code rejects (or omits one it takes) is worse than no message.
|
|
137
|
+
*/
|
|
138
|
+
export const BROWSER_CHANNELS = ['chrome', 'edge', 'auto', 'bundled', 'sidebar'] as const
|
|
139
|
+
|
|
132
140
|
/**
|
|
133
141
|
* Coerce the browser channel, keeping the default when it is not one of the
|
|
134
142
|
* known choices. A hand-edited typo must not silently select a different browser.
|
|
143
|
+
*
|
|
144
|
+
* Coercing is right; coercing QUIETLY is not. A typo and "the setting did nothing" look exactly
|
|
145
|
+
* the same to whoever typed it, which is why every discarded value is reported.
|
|
135
146
|
* @param value - the raw value from the settings document.
|
|
147
|
+
* @param warn - where to report a discarded value, when the caller has somewhere to put it.
|
|
136
148
|
* @returns a valid channel.
|
|
137
149
|
*/
|
|
138
|
-
function resolveChannel(value: unknown): BrowserChannel {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
function resolveChannel(value: unknown, warn?: (message: string) => void): BrowserChannel {
|
|
151
|
+
// Absent is the ordinary case, not a mistake: a document that never mentioned the channel gets
|
|
152
|
+
// the default without a word.
|
|
153
|
+
if (value === undefined) return DEFAULT_SETTINGS.browser.channel
|
|
154
|
+
if ((BROWSER_CHANNELS as readonly unknown[]).includes(value)) return value as BrowserChannel
|
|
155
|
+
warn?.(
|
|
156
|
+
`dsh-builtin-browser: browser.channel ${JSON.stringify(value)} is not a known channel, so the `
|
|
157
|
+
+ `default "${DEFAULT_SETTINGS.browser.channel}" was used instead. Expected one of: `
|
|
158
|
+
+ `${BROWSER_CHANNELS.join(', ')}.`,
|
|
159
|
+
)
|
|
160
|
+
return DEFAULT_SETTINGS.browser.channel
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/** Coerce one positive-integer field, keeping the default when invalid. */
|
|
@@ -181,7 +200,7 @@ function section(source: Record<string, unknown>, key: string): Record<string, u
|
|
|
181
200
|
* @param raw - the parsed file contents.
|
|
182
201
|
* @returns the resolved settings.
|
|
183
202
|
*/
|
|
184
|
-
export function resolveSettings(raw: unknown): BrowserSettings {
|
|
203
|
+
export function resolveSettings(raw: unknown, warn?: (message: string) => void): BrowserSettings {
|
|
185
204
|
const source = typeof raw === 'object' && raw !== null ? raw as Record<string, unknown> : {}
|
|
186
205
|
const history = section(source, 'history')
|
|
187
206
|
const cookies = section(source, 'cookies')
|
|
@@ -208,7 +227,7 @@ export function resolveSettings(raw: unknown): BrowserSettings {
|
|
|
208
227
|
virtualCursor: bool(ui?.virtualCursor, DEFAULT_SETTINGS.ui.virtualCursor),
|
|
209
228
|
},
|
|
210
229
|
vision: { strategy: vision?.strategy === 'nonVisual' ? 'nonVisual' : DEFAULT_SETTINGS.vision.strategy },
|
|
211
|
-
browser: { channel: resolveChannel(browser?.channel) },
|
|
230
|
+
browser: { channel: resolveChannel(browser?.channel, warn) },
|
|
212
231
|
credentials: {
|
|
213
232
|
// `gate` returns false for anything present-and-not-a-boolean, so an unreadable section
|
|
214
233
|
// is refused here for free: the section itself is the "present but wrong" value.
|
|
@@ -244,7 +263,7 @@ export class SettingsStore {
|
|
|
244
263
|
/**
|
|
245
264
|
* @param file - settings file path (defaults beside the browser profile).
|
|
246
265
|
*/
|
|
247
|
-
constructor(private readonly file: string = settingsPath()) {}
|
|
266
|
+
constructor(private readonly file: string = settingsPath(), private readonly log?: (message: string) => void) {}
|
|
248
267
|
|
|
249
268
|
/** Path this store reads and writes. */
|
|
250
269
|
path(): string {
|
|
@@ -291,7 +310,7 @@ export class SettingsStore {
|
|
|
291
310
|
// value in it: the document looked correct, and the plugin quietly ran on
|
|
292
311
|
// defaults — including switches the user had just turned off.
|
|
293
312
|
const body = text?.replace(/^\uFEFF/, '')
|
|
294
|
-
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body))
|
|
313
|
+
this.cached = body === undefined ? DEFAULT_SETTINGS : resolveSettings(JSON.parse(body), m => this.log?.(m))
|
|
295
314
|
} catch {
|
|
296
315
|
// A document that EXISTS but cannot be parsed is not the same as no document at all: the
|
|
297
316
|
// first is a corrupted or half-written file whose contents were the operator's decisions,
|
|
@@ -311,7 +330,7 @@ export class SettingsStore {
|
|
|
311
330
|
*/
|
|
312
331
|
update(patch: unknown): BrowserSettings {
|
|
313
332
|
const current = this.get()
|
|
314
|
-
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch))
|
|
333
|
+
const merged = resolveSettings(deepMerge(current as unknown as Record<string, unknown>, patch), m => this.log?.(m))
|
|
315
334
|
try {
|
|
316
335
|
mkdirSync(dirname(this.file), { recursive: true })
|
|
317
336
|
writeFileAtomic(this.file, `${JSON.stringify(merged, null, 2)}\n`)
|
|
@@ -146,6 +146,38 @@ function agentOf(exec: unknown): ExecAgent | undefined {
|
|
|
146
146
|
return (exec as { agent?: ExecAgent } | undefined)?.agent
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Refuse the bare DSH web root, which answers 401 and looks like a real page.
|
|
151
|
+
*
|
|
152
|
+
* The address comes from `DSH_WEB_URL`, which the harness exports to its children — NOT from a
|
|
153
|
+
* hardcoded port. This machine's GUI has run on 3080 and on 19387, so a number would stop matching
|
|
154
|
+
* exactly when the guard is needed, and the trap is not a port anyway: it is the SHAPE. A loopback
|
|
155
|
+
* address at its root with no token renders as an unauthenticated 401 page, which an agent reads
|
|
156
|
+
* as the site it meant to open.
|
|
157
|
+
* @param rawUrl - the address a caller asked to open.
|
|
158
|
+
*/
|
|
159
|
+
function assertNotBareDshWebUrl(rawUrl: string): void {
|
|
160
|
+
const advertised = process.env.DSH_WEB_URL
|
|
161
|
+
if (typeof advertised !== 'string' || advertised === '') return
|
|
162
|
+
let target: URL
|
|
163
|
+
let dsh: URL
|
|
164
|
+
try {
|
|
165
|
+
target = new URL(rawUrl)
|
|
166
|
+
dsh = new URL(advertised)
|
|
167
|
+
} catch {
|
|
168
|
+
return // not a URL this guard can judge; the provider's own admission is the next gate
|
|
169
|
+
}
|
|
170
|
+
if (target.origin !== dsh.origin) return
|
|
171
|
+
if (target.pathname !== '/') return
|
|
172
|
+
// The authenticated URL printed by `dsh web` carries a token; the bare root does not.
|
|
173
|
+
if (target.searchParams.has('token')) return
|
|
174
|
+
throw new Error(
|
|
175
|
+
'browser_open: refusing the bare DSH web root (' + dsh.origin + '/) because it carries no '
|
|
176
|
+
+ 'authentication token, so it answers with a 401 page that reads like a real site. Open the '
|
|
177
|
+
+ 'target site instead; to open DSH itself, use the authenticated URL printed by `dsh web`.',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
149
181
|
/**
|
|
150
182
|
* Read a target's `index`, refusing the values that cannot mean anything.
|
|
151
183
|
*
|
|
@@ -319,7 +351,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
319
351
|
|
|
320
352
|
ctx.tools.register(defineTool({
|
|
321
353
|
name: 'browser_open',
|
|
322
|
-
description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot.',
|
|
354
|
+
description: 'Open a URL in the shared browser window. Opens this task\'s browser session on first use; optionally opens in a new tab. Returns the resulting page snapshot. A fresh session starts on a blank, uncommitted tab: call this with the REAL target URL before reading, because reading that tab returns an empty page instead of waiting. Never treat the bare DSH host (e.g. http://127.0.0.1:3080/) as the target website — it requires the authenticated URL printed by `dsh web`, and the bare root is refused outright.',
|
|
323
355
|
parameters: {
|
|
324
356
|
url: { type: 'string', required: true, description: 'The URL to open (HTTP/HTTPS).' },
|
|
325
357
|
newTab: { type: 'boolean', description: 'Open in a new tab instead of the active one.' },
|
|
@@ -358,6 +390,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
358
390
|
isConcurrencySafe: () => false, // opens tabs / navigates; exclusive within a task
|
|
359
391
|
async execute(args, exec) {
|
|
360
392
|
assertAllowed(state, 'browser_open', taskKey(exec))
|
|
393
|
+
assertNotBareDshWebUrl(args.url)
|
|
361
394
|
const browser = ctx.get('browser')
|
|
362
395
|
if (browser === undefined) throw new Error('tool-browser: browser service unavailable')
|
|
363
396
|
const session = await ensureSession(browser, state, taskKey(exec), agentOf(exec))
|