@jobshimo/browser-link 0.28.8 → 0.29.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.
Files changed (39) hide show
  1. package/dist/activity/extract.js +2 -0
  2. package/dist/activity/extract.js.map +1 -1
  3. package/dist/agent-instructions/content.js +7 -1
  4. package/dist/agent-instructions/content.js.map +1 -1
  5. package/dist/bridge/events.d.ts +1 -1
  6. package/dist/bridge/events.js.map +1 -1
  7. package/dist/cdp/support.d.ts +2 -1
  8. package/dist/cdp/support.js +6 -1
  9. package/dist/cdp/support.js.map +1 -1
  10. package/dist/cli.js +8 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/about.js +2 -0
  13. package/dist/commands/about.js.map +1 -1
  14. package/dist/commands/config.d.ts +12 -0
  15. package/dist/commands/config.js +49 -4
  16. package/dist/commands/config.js.map +1 -1
  17. package/dist/config.d.ts +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/downloads.d.ts +127 -0
  20. package/dist/downloads.js +303 -0
  21. package/dist/downloads.js.map +1 -0
  22. package/dist/extension/background.js +49 -0
  23. package/dist/extension/background.js.map +1 -1
  24. package/dist/extension/download-capture.d.ts +96 -0
  25. package/dist/extension/download-capture.js +131 -0
  26. package/dist/extension/download-capture.js.map +1 -0
  27. package/dist/extension/manifest.json +1 -1
  28. package/dist/permissions.js +6 -0
  29. package/dist/permissions.js.map +1 -1
  30. package/dist/server.js +8 -0
  31. package/dist/server.js.map +1 -1
  32. package/dist/tools/browser-definitions.js +45 -1
  33. package/dist/tools/browser-definitions.js.map +1 -1
  34. package/dist/tools/browser-dispatch.d.ts +12 -1
  35. package/dist/tools/browser-dispatch.js +134 -0
  36. package/dist/tools/browser-dispatch.js.map +1 -1
  37. package/dist/tools/server-instructions.js +18 -4
  38. package/dist/tools/server-instructions.js.map +1 -1
  39. package/package.json +3 -3
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `browser.download` support: everything the tool needs that is NOT talking
3
+ * to Chrome — filename policy, the download directory, the file write, the
4
+ * in-page fetch expression and the shaping of the result the agent sees.
5
+ *
6
+ * WHY THE BYTES COME THROUGH THE SERVER. The obvious design — tell Chrome
7
+ * "save downloads to <dir>, never ask" with CDP `Browser.setDownloadBehavior`
8
+ * — is not available to us: Chromium refuses it (and the tab-scoped
9
+ * `Page.setDownloadBehavior`, which delegates to the same check) for any
10
+ * `chrome.debugger` client, because the extension debugger host reports
11
+ * `MayWriteLocalFiles() == false`. And `chrome.downloads.download({saveAs:
12
+ * false})` does NOT suppress the dialog either: Chrome still prompts when the
13
+ * user enabled "Ask where to save each file". So instead the bytes are
14
+ * fetched IN THE PAGE (with the page's own cookies) and written here, into a
15
+ * directory only the human configures.
16
+ *
17
+ * Chrome's download manager never SAVES anything. In url mode it is not
18
+ * involved at all. In selector mode the extension uses it only to catch the
19
+ * download a click starts: it hooks `chrome.downloads.onDeterminingFilename`,
20
+ * which Chrome consults before prompting, cancels and erases that download
21
+ * there, and hands its URL to the same in-page fetch (see the extension's
22
+ * `download-capture.ts`).
23
+ */
24
+ export declare const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30000;
25
+ export declare const MAX_DOWNLOAD_TIMEOUT_MS = 120000;
26
+ /** Hard cap on one download's size. The bytes travel base64-encoded inside a
27
+ * CDP `Runtime.evaluate` result and then a bridge frame, so this also bounds
28
+ * the memory both hops can be asked to hold. */
29
+ export declare const MAX_DOWNLOAD_BYTES: number;
30
+ /** Extra bridge timeout on top of each hop's own budget (selector mode has
31
+ * two hops: capture, then fetch), for the round trip only — the capture and
32
+ * the in-page fetch enforce their budgets themselves and always answer, so
33
+ * this is spent only when the bridge itself stalls. */
34
+ export declare const DOWNLOAD_BRIDGE_SLACK_MS = 10000;
35
+ /**
36
+ * Validate a filename the AGENT asked for. Strict on purpose: the agent may
37
+ * name the file but never choose where it lands, so anything that could be
38
+ * read as a path (separators, `..`, a drive prefix) is rejected outright
39
+ * rather than quietly cleaned up.
40
+ */
41
+ export declare function validateRequestedFilename(name: unknown): string;
42
+ /**
43
+ * Make a filename that came from the PAGE (Chrome's suggestion, a
44
+ * Content-Disposition header, a URL) safe to write: keep the last path
45
+ * segment, replace reserved characters, drop control characters and leading
46
+ * or trailing dots/spaces, and defuse Windows device names with a `_`
47
+ * prefix. Cleaned up rather than rejected: the page, not the agent, chose
48
+ * it. Returns null when nothing usable is left.
49
+ */
50
+ export declare function sanitizeDerivedFilename(raw: string | null | undefined): string | null;
51
+ /** Extract the filename from a Content-Disposition header, preferring the
52
+ * RFC 5987 `filename*=` form (it carries non-ASCII names correctly). */
53
+ export declare function filenameFromContentDisposition(header: string | null | undefined): string | null;
54
+ /** Last path segment of an http(s) URL, decoded. Null for `blob:`/`data:`
55
+ * URLs (their "path" is an opaque id or the payload itself). */
56
+ export declare function filenameFromUrl(url: string | null | undefined): string | null;
57
+ export interface FilenameSources {
58
+ /** Already validated by `validateRequestedFilename`. */
59
+ requested?: string;
60
+ /** Chrome's own suggestion for a captured download. */
61
+ suggested?: string | null;
62
+ contentDisposition?: string | null;
63
+ url?: string | null;
64
+ }
65
+ /** requested → Chrome's suggestion → Content-Disposition → URL → "download". */
66
+ export declare function pickFilename(sources: FilenameSources): string;
67
+ export declare function clampDownloadTimeoutMs(value: unknown): number;
68
+ /**
69
+ * Where downloads land: the human-configured `downloads.dir` when it is an
70
+ * absolute path, otherwise `<dataDir>/downloads`. config.json is untrusted
71
+ * (hand-edited, corrupted), so a relative or non-string value falls back to
72
+ * the default instead of resolving against whatever the cwd happens to be.
73
+ */
74
+ export declare function resolveDownloadsDir(cfg: {
75
+ downloadsDir?: unknown;
76
+ }, dataDir: string): string;
77
+ /**
78
+ * Write `data` as `filename` inside `dir`, creating `dir` if needed. Never
79
+ * overwrites: an existing name becomes `name (1).ext`, `name (2).ext`, …
80
+ * (the `wx` flag makes the existence check and the create one atomic step).
81
+ * Returns the absolute path written.
82
+ */
83
+ export declare function writeDownloadFile(dir: string, filename: string, data: Buffer): string;
84
+ /** What the in-page fetch expression resolves to. */
85
+ export interface FetchDownloadOutcome {
86
+ state: 'complete' | 'interrupted' | 'timeout';
87
+ status?: number;
88
+ mime?: string | null;
89
+ content_disposition?: string | null;
90
+ final_url?: string | null;
91
+ /** Bytes received — the full size on `complete`, partial otherwise. */
92
+ bytes: number;
93
+ /** Present only on `complete`. */
94
+ data_b64?: string;
95
+ error?: string;
96
+ }
97
+ /**
98
+ * Build the expression `Runtime.evaluate` runs in the page: fetch `url` with
99
+ * the page's credentials, stream the body (so `maxBytes` is enforced while
100
+ * reading, not after buffering everything), and base64 it. Every exit path
101
+ * resolves to a `FetchDownloadOutcome` — a timeout or an error reports how
102
+ * far it got rather than throwing, so the agent always gets partial info.
103
+ */
104
+ export declare function buildFetchDownloadJs(opts: {
105
+ url: string;
106
+ timeoutMs: number;
107
+ maxBytes: number;
108
+ }): string;
109
+ /** The result `browser.download` returns to the agent. */
110
+ export interface DownloadResult {
111
+ ok: boolean;
112
+ state: 'complete' | 'interrupted' | 'timeout';
113
+ path: string | null;
114
+ filename: string | null;
115
+ bytes: number;
116
+ mime: string | null;
117
+ url: string | null;
118
+ error?: string;
119
+ }
120
+ /**
121
+ * Turn the in-page fetch outcome into the agent-facing result, writing the
122
+ * file on `complete`. A failed write downgrades to `interrupted` with the
123
+ * error — the agent must never be told a file exists when it does not.
124
+ */
125
+ export declare function shapeDownloadResult(outcome: FetchDownloadOutcome, sources: FilenameSources & {
126
+ url: string;
127
+ }, save: (filename: string, data: Buffer) => string): DownloadResult;
@@ -0,0 +1,303 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { extname, isAbsolute, join } from 'node:path';
3
+ /**
4
+ * `browser.download` support: everything the tool needs that is NOT talking
5
+ * to Chrome — filename policy, the download directory, the file write, the
6
+ * in-page fetch expression and the shaping of the result the agent sees.
7
+ *
8
+ * WHY THE BYTES COME THROUGH THE SERVER. The obvious design — tell Chrome
9
+ * "save downloads to <dir>, never ask" with CDP `Browser.setDownloadBehavior`
10
+ * — is not available to us: Chromium refuses it (and the tab-scoped
11
+ * `Page.setDownloadBehavior`, which delegates to the same check) for any
12
+ * `chrome.debugger` client, because the extension debugger host reports
13
+ * `MayWriteLocalFiles() == false`. And `chrome.downloads.download({saveAs:
14
+ * false})` does NOT suppress the dialog either: Chrome still prompts when the
15
+ * user enabled "Ask where to save each file". So instead the bytes are
16
+ * fetched IN THE PAGE (with the page's own cookies) and written here, into a
17
+ * directory only the human configures.
18
+ *
19
+ * Chrome's download manager never SAVES anything. In url mode it is not
20
+ * involved at all. In selector mode the extension uses it only to catch the
21
+ * download a click starts: it hooks `chrome.downloads.onDeterminingFilename`,
22
+ * which Chrome consults before prompting, cancels and erases that download
23
+ * there, and hands its URL to the same in-page fetch (see the extension's
24
+ * `download-capture.ts`).
25
+ */
26
+ export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 30_000;
27
+ export const MAX_DOWNLOAD_TIMEOUT_MS = 120_000;
28
+ /** Hard cap on one download's size. The bytes travel base64-encoded inside a
29
+ * CDP `Runtime.evaluate` result and then a bridge frame, so this also bounds
30
+ * the memory both hops can be asked to hold. */
31
+ export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
32
+ /** Extra bridge timeout on top of each hop's own budget (selector mode has
33
+ * two hops: capture, then fetch), for the round trip only — the capture and
34
+ * the in-page fetch enforce their budgets themselves and always answer, so
35
+ * this is spent only when the bridge itself stalls. */
36
+ export const DOWNLOAD_BRIDGE_SLACK_MS = 10_000;
37
+ const MAX_FILENAME_LENGTH = 200;
38
+ // Characters no filename may carry on any OS we run on (Windows is the
39
+ // strictest) — plus the two path separators.
40
+ // eslint-disable-next-line no-control-regex
41
+ const RESERVED_CHARS = /[<>:"/\\|?*\u0000-\u001f]/;
42
+ // eslint-disable-next-line no-control-regex
43
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
44
+ /**
45
+ * Validate a filename the AGENT asked for. Strict on purpose: the agent may
46
+ * name the file but never choose where it lands, so anything that could be
47
+ * read as a path (separators, `..`, a drive prefix) is rejected outright
48
+ * rather than quietly cleaned up.
49
+ */
50
+ export function validateRequestedFilename(name) {
51
+ const fail = (why) => {
52
+ throw new Error(`browser.download: filename ${why} — pass a plain basename like "report.pdf"`);
53
+ };
54
+ if (typeof name !== 'string')
55
+ return fail('must be a string');
56
+ if (name.trim().length === 0)
57
+ return fail('must not be empty');
58
+ if (name.length > MAX_FILENAME_LENGTH)
59
+ return fail(`must be at most ${MAX_FILENAME_LENGTH} characters`);
60
+ if (name === '.' || name === '..')
61
+ return fail('must not be "." or ".."');
62
+ if (RESERVED_CHARS.test(name))
63
+ return fail('must not contain path separators or reserved characters');
64
+ if (/[.\s]$/.test(name))
65
+ return fail('must not end with a dot or a space');
66
+ if (WINDOWS_DEVICE_NAME.test(name)) {
67
+ return fail('must not be a Windows device name (CON, PRN, AUX, NUL, COM1-9, LPT1-9)');
68
+ }
69
+ return name;
70
+ }
71
+ /**
72
+ * Windows device names — `NUL`, `con.txt`, `LPT1.log`: with or without an
73
+ * extension, any case. Writing to one on Windows hits the device (or a
74
+ * normalized name), so the server would report a file that does not exist.
75
+ */
76
+ const WINDOWS_DEVICE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
77
+ /**
78
+ * Make a filename that came from the PAGE (Chrome's suggestion, a
79
+ * Content-Disposition header, a URL) safe to write: keep the last path
80
+ * segment, replace reserved characters, drop control characters and leading
81
+ * or trailing dots/spaces, and defuse Windows device names with a `_`
82
+ * prefix. Cleaned up rather than rejected: the page, not the agent, chose
83
+ * it. Returns null when nothing usable is left.
84
+ */
85
+ export function sanitizeDerivedFilename(raw) {
86
+ if (typeof raw !== 'string')
87
+ return null;
88
+ const lastSegment = raw.split(/[/\\]/).pop() ?? '';
89
+ const cleaned = lastSegment
90
+ .replace(CONTROL_CHARS, '')
91
+ .replace(/[<>:"|?*]/g, '_')
92
+ .replace(/^[.\s]+/, '')
93
+ .slice(0, MAX_FILENAME_LENGTH - 1)
94
+ .replace(/[.\s]+$/, '');
95
+ if (cleaned.length === 0)
96
+ return null;
97
+ return WINDOWS_DEVICE_NAME.test(cleaned) ? `_${cleaned}` : cleaned;
98
+ }
99
+ /** Extract the filename from a Content-Disposition header, preferring the
100
+ * RFC 5987 `filename*=` form (it carries non-ASCII names correctly). */
101
+ export function filenameFromContentDisposition(header) {
102
+ if (typeof header !== 'string')
103
+ return null;
104
+ const star = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
105
+ if (star) {
106
+ try {
107
+ return decodeURIComponent(star[2].trim().replace(/^"|"$/g, ''));
108
+ }
109
+ catch {
110
+ // Malformed percent-encoding — fall through to the plain form.
111
+ }
112
+ }
113
+ const plain = /filename\s*=\s*("[^"]*"|[^;]+)/i.exec(header);
114
+ if (!plain)
115
+ return null;
116
+ const value = plain[1].trim().replace(/^"|"$/g, '');
117
+ return value.length > 0 ? value : null;
118
+ }
119
+ /** Last path segment of an http(s) URL, decoded. Null for `blob:`/`data:`
120
+ * URLs (their "path" is an opaque id or the payload itself). */
121
+ export function filenameFromUrl(url) {
122
+ if (typeof url !== 'string')
123
+ return null;
124
+ let parsed;
125
+ try {
126
+ parsed = new URL(url);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
132
+ return null;
133
+ const segment = parsed.pathname.split('/').pop() ?? '';
134
+ if (segment.length === 0)
135
+ return null;
136
+ try {
137
+ return decodeURIComponent(segment);
138
+ }
139
+ catch {
140
+ return segment;
141
+ }
142
+ }
143
+ /** requested → Chrome's suggestion → Content-Disposition → URL → "download". */
144
+ export function pickFilename(sources) {
145
+ if (sources.requested !== undefined)
146
+ return sources.requested;
147
+ return (sanitizeDerivedFilename(sources.suggested) ??
148
+ sanitizeDerivedFilename(filenameFromContentDisposition(sources.contentDisposition)) ??
149
+ sanitizeDerivedFilename(filenameFromUrl(sources.url)) ??
150
+ 'download');
151
+ }
152
+ export function clampDownloadTimeoutMs(value) {
153
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
154
+ return DEFAULT_DOWNLOAD_TIMEOUT_MS;
155
+ }
156
+ return Math.min(Math.floor(value), MAX_DOWNLOAD_TIMEOUT_MS);
157
+ }
158
+ /**
159
+ * Where downloads land: the human-configured `downloads.dir` when it is an
160
+ * absolute path, otherwise `<dataDir>/downloads`. config.json is untrusted
161
+ * (hand-edited, corrupted), so a relative or non-string value falls back to
162
+ * the default instead of resolving against whatever the cwd happens to be.
163
+ */
164
+ export function resolveDownloadsDir(cfg, dataDir) {
165
+ const configured = cfg.downloadsDir;
166
+ if (typeof configured === 'string' && configured.length > 0 && isAbsolute(configured)) {
167
+ return configured;
168
+ }
169
+ return join(dataDir, 'downloads');
170
+ }
171
+ /**
172
+ * Write `data` as `filename` inside `dir`, creating `dir` if needed. Never
173
+ * overwrites: an existing name becomes `name (1).ext`, `name (2).ext`, …
174
+ * (the `wx` flag makes the existence check and the create one atomic step).
175
+ * Returns the absolute path written.
176
+ */
177
+ export function writeDownloadFile(dir, filename, data) {
178
+ mkdirSync(dir, { recursive: true });
179
+ const ext = extname(filename);
180
+ const stem = ext.length > 0 ? filename.slice(0, -ext.length) : filename;
181
+ for (let attempt = 0; attempt < 1000; attempt++) {
182
+ const name = attempt === 0 ? filename : `${stem} (${attempt})${ext}`;
183
+ const path = join(dir, name);
184
+ try {
185
+ writeFileSync(path, data, { flag: 'wx' });
186
+ return path;
187
+ }
188
+ catch (err) {
189
+ if (err.code !== 'EEXIST')
190
+ throw err;
191
+ }
192
+ }
193
+ throw new Error(`could not find a free filename for ${filename} in ${dir}`);
194
+ }
195
+ /**
196
+ * Build the expression `Runtime.evaluate` runs in the page: fetch `url` with
197
+ * the page's credentials, stream the body (so `maxBytes` is enforced while
198
+ * reading, not after buffering everything), and base64 it. Every exit path
199
+ * resolves to a `FetchDownloadOutcome` — a timeout or an error reports how
200
+ * far it got rather than throwing, so the agent always gets partial info.
201
+ */
202
+ export function buildFetchDownloadJs(opts) {
203
+ const url = JSON.stringify(opts.url);
204
+ const timeoutMs = Math.floor(opts.timeoutMs);
205
+ const maxBytes = Math.floor(opts.maxBytes);
206
+ return `(async () => {
207
+ const url = ${url};
208
+ const timeoutMs = ${timeoutMs};
209
+ const maxBytes = ${maxBytes};
210
+ const ctrl = new AbortController();
211
+ let timedOut = false;
212
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, timeoutMs);
213
+ const out = { state: 'interrupted', bytes: 0, mime: null, content_disposition: null, final_url: null };
214
+ try {
215
+ const res = await fetch(url, { credentials: 'include', signal: ctrl.signal });
216
+ out.status = res.status;
217
+ out.mime = res.headers.get('content-type');
218
+ out.content_disposition = res.headers.get('content-disposition');
219
+ out.final_url = res.url || url;
220
+ if (!res.ok) {
221
+ out.error = 'HTTP ' + res.status;
222
+ return out;
223
+ }
224
+ const chunks = [];
225
+ if (res.body) {
226
+ const reader = res.body.getReader();
227
+ for (;;) {
228
+ const step = await reader.read();
229
+ if (step.done) break;
230
+ out.bytes += step.value.byteLength;
231
+ if (out.bytes > maxBytes) {
232
+ ctrl.abort();
233
+ out.error = 'download exceeds the ' + maxBytes + ' bytes cap';
234
+ return out;
235
+ }
236
+ chunks.push(step.value);
237
+ }
238
+ }
239
+ let binary = '';
240
+ for (const chunk of chunks) {
241
+ for (let i = 0; i < chunk.length; i += 0x8000) {
242
+ binary += String.fromCharCode.apply(null, chunk.subarray(i, i + 0x8000));
243
+ }
244
+ }
245
+ out.data_b64 = btoa(binary);
246
+ out.state = 'complete';
247
+ return out;
248
+ } catch (err) {
249
+ if (timedOut) {
250
+ out.state = 'timeout';
251
+ out.error = 'timed out after ' + timeoutMs + 'ms';
252
+ } else {
253
+ out.error = err && err.message ? err.message : String(err);
254
+ }
255
+ return out;
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ })()`;
260
+ }
261
+ /**
262
+ * Turn the in-page fetch outcome into the agent-facing result, writing the
263
+ * file on `complete`. A failed write downgrades to `interrupted` with the
264
+ * error — the agent must never be told a file exists when it does not.
265
+ */
266
+ export function shapeDownloadResult(outcome, sources, save) {
267
+ const filename = pickFilename({
268
+ ...sources,
269
+ contentDisposition: outcome.content_disposition,
270
+ url: outcome.final_url ?? sources.url,
271
+ });
272
+ const base = {
273
+ filename,
274
+ mime: outcome.mime ?? null,
275
+ url: outcome.final_url ?? sources.url,
276
+ };
277
+ if (outcome.state === 'complete' && typeof outcome.data_b64 === 'string') {
278
+ const data = Buffer.from(outcome.data_b64, 'base64');
279
+ try {
280
+ const path = save(filename, data);
281
+ return { ok: true, state: 'complete', path, bytes: data.length, ...base };
282
+ }
283
+ catch (err) {
284
+ return {
285
+ ok: false,
286
+ state: 'interrupted',
287
+ path: null,
288
+ bytes: data.length,
289
+ ...base,
290
+ error: `could not write the file: ${err instanceof Error ? err.message : String(err)}`,
291
+ };
292
+ }
293
+ }
294
+ return {
295
+ ok: false,
296
+ state: outcome.state === 'timeout' ? 'timeout' : 'interrupted',
297
+ path: null,
298
+ bytes: outcome.bytes,
299
+ ...base,
300
+ error: outcome.error ?? 'download did not complete',
301
+ };
302
+ }
303
+ //# sourceMappingURL=downloads.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"downloads.js","sourceRoot":"","sources":["../src/downloads.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs, buildFocusJs, buildStateJs, buildDragProbeJs, } from './inpage/builders.js';
2
2
  import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
3
3
  import { resolveSettleParams, settleSafely } from './settle.js';
4
+ import { captureAround, createDownloadCapture } from './download-capture.js';
4
5
  import { runFlow, } from './flow.js';
5
6
  import { MAX_DETACHED_FLOW_MS, appendFlowHistory, createFlowRegistry, detachedFlowKey, detachedFlowRecordsToEvict, finishedDetachedFlowRecord, flowHistoryKey, flowIdFromDetachedFlowKey, minimalDetachedFlowRecord, parseDetachedFlowRecord, parseFlowHistory, startedDetachedFlowRecord, terminatedDetachedFlowRecord, terminatedFlowHistoryEntry, toFlowStatus, toHistoryEntry, } from './flow-registry.js';
6
7
  import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
@@ -1895,6 +1896,52 @@ async function handleDialogRespond(tabId, msg, p, optStr) {
1895
1896
  result: { accepted: accept },
1896
1897
  };
1897
1898
  }
1899
+ /** One worker-wide capture: `chrome.downloads` events are not tied to a tab,
1900
+ * so only one `browser.download` may wait for a download at a time. */
1901
+ const downloadCapture = createDownloadCapture(chrome.downloads);
1902
+ /** The tab's origin for `mayComeFrom`, or undefined when it has none worth
1903
+ * matching (opaque `null` origins, unparseable URLs). */
1904
+ function downloadOriginOf(url) {
1905
+ try {
1906
+ const origin = new URL(url).origin;
1907
+ return origin === 'null' ? undefined : origin;
1908
+ }
1909
+ catch {
1910
+ return undefined;
1911
+ }
1912
+ }
1913
+ /**
1914
+ * `browser.download`'s selector mode: arm the capture, click, and wait for
1915
+ * the download the click starts. The capture cancels that download before
1916
+ * Chrome can show "Save as" (see `download-capture.ts`) and hands its URL
1917
+ * back; the server then fetches the bytes in the page and writes the file.
1918
+ * A click that starts nothing within `timeout_ms` answers `state: "timeout"`.
1919
+ */
1920
+ async function handleDownloadCapture(tabId, msg, p) {
1921
+ const selector = typeof p.selector === 'string' ? p.selector : '';
1922
+ // No local default: the server always sends a clamped budget, and a second
1923
+ // copy of its default here would only drift.
1924
+ if (typeof p.timeout_ms !== 'number' || !(p.timeout_ms > 0)) {
1925
+ return { kind: 'tool.response', id: msg.id, ok: false, error: 'download: timeout_ms required' };
1926
+ }
1927
+ const timeoutMs = p.timeout_ms;
1928
+ // Narrow the capture to downloads that may come from this tab's origin —
1929
+ // `chrome.downloads` items carry no tab id (see `mayComeFrom`).
1930
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
1931
+ const origin = tab?.url ? downloadOriginOf(tab.url) : undefined;
1932
+ const outcome = await captureAround(downloadCapture, { timeoutMs, ...(origin ? { origin } : {}) },
1933
+ // settle_ms: 0 — the thing to wait for is the download, not DOM quiet.
1934
+ () => performClick(tabId, { selector, settle_ms: 0 }));
1935
+ if (!outcome.ok) {
1936
+ return { kind: 'tool.response', id: msg.id, ok: false, error: outcome.error };
1937
+ }
1938
+ return {
1939
+ kind: 'tool.response',
1940
+ id: msg.id,
1941
+ ok: true,
1942
+ result: outcome.captured ? { state: 'captured', ...outcome.captured } : { state: 'timeout' },
1943
+ };
1944
+ }
1898
1945
  async function handleSetPermission(msg, str) {
1899
1946
  const origin = str('origin');
1900
1947
  const name = str('name');
@@ -2059,6 +2106,8 @@ async function handleTool(state, msg) {
2059
2106
  return await handleDialogRespond(tabId, msg, p, optStr);
2060
2107
  case 'set_permission':
2061
2108
  return await handleSetPermission(msg, str);
2109
+ case 'download':
2110
+ return await handleDownloadCapture(tabId, msg, p);
2062
2111
  default:
2063
2112
  return { kind: 'tool.response', id: msg.id, ok: false, error: `Unknown tool: ${msg.tool}` };
2064
2113
  }