@flrande/browserctl 0.1.0-dev.7.1

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README-CN.md +66 -0
  3. package/README.md +66 -0
  4. package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
  5. package/apps/browserctl/src/commands/act.ts +20 -0
  6. package/apps/browserctl/src/commands/common.test.ts +87 -0
  7. package/apps/browserctl/src/commands/common.ts +191 -0
  8. package/apps/browserctl/src/commands/console-list.ts +20 -0
  9. package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
  10. package/apps/browserctl/src/commands/cookie-get.ts +18 -0
  11. package/apps/browserctl/src/commands/cookie-set.ts +22 -0
  12. package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
  13. package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
  14. package/apps/browserctl/src/commands/dom-query.ts +18 -0
  15. package/apps/browserctl/src/commands/download-trigger.ts +22 -0
  16. package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
  17. package/apps/browserctl/src/commands/download-wait.ts +27 -0
  18. package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
  19. package/apps/browserctl/src/commands/frame-list.ts +16 -0
  20. package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
  21. package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
  22. package/apps/browserctl/src/commands/profile-list.ts +16 -0
  23. package/apps/browserctl/src/commands/profile-use.ts +18 -0
  24. package/apps/browserctl/src/commands/response-body.ts +24 -0
  25. package/apps/browserctl/src/commands/screenshot.ts +16 -0
  26. package/apps/browserctl/src/commands/snapshot.ts +16 -0
  27. package/apps/browserctl/src/commands/status.ts +10 -0
  28. package/apps/browserctl/src/commands/storage-get.ts +20 -0
  29. package/apps/browserctl/src/commands/storage-set.ts +22 -0
  30. package/apps/browserctl/src/commands/tab-close.ts +20 -0
  31. package/apps/browserctl/src/commands/tab-focus.ts +20 -0
  32. package/apps/browserctl/src/commands/tab-open.ts +19 -0
  33. package/apps/browserctl/src/commands/tabs.ts +13 -0
  34. package/apps/browserctl/src/commands/upload-arm.ts +26 -0
  35. package/apps/browserctl/src/daemon-client.test.ts +253 -0
  36. package/apps/browserctl/src/daemon-client.ts +632 -0
  37. package/apps/browserctl/src/e2e.test.ts +99 -0
  38. package/apps/browserctl/src/main.test.ts +215 -0
  39. package/apps/browserctl/src/main.ts +372 -0
  40. package/apps/browserctl/src/smoke.test.ts +16 -0
  41. package/apps/browserctl/src/smoke.ts +5 -0
  42. package/apps/browserd/src/bootstrap.ts +432 -0
  43. package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
  44. package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
  45. package/apps/browserd/src/container.ts +1531 -0
  46. package/apps/browserd/src/main.test.ts +864 -0
  47. package/apps/browserd/src/main.ts +7 -0
  48. package/bin/browserctl.cjs +21 -0
  49. package/bin/browserd.cjs +21 -0
  50. package/extensions/chrome-relay/README-CN.md +38 -0
  51. package/extensions/chrome-relay/README.md +38 -0
  52. package/extensions/chrome-relay/background.js +1687 -0
  53. package/extensions/chrome-relay/manifest.json +15 -0
  54. package/extensions/chrome-relay/popup.html +369 -0
  55. package/extensions/chrome-relay/popup.js +972 -0
  56. package/package.json +51 -0
  57. package/packages/core/src/bootstrap.test.ts +10 -0
  58. package/packages/core/src/driver-registry.test.ts +45 -0
  59. package/packages/core/src/driver-registry.ts +22 -0
  60. package/packages/core/src/driver.ts +47 -0
  61. package/packages/core/src/index.ts +5 -0
  62. package/packages/core/src/ref-cache.test.ts +61 -0
  63. package/packages/core/src/ref-cache.ts +28 -0
  64. package/packages/core/src/session-store.test.ts +49 -0
  65. package/packages/core/src/session-store.ts +33 -0
  66. package/packages/core/src/types.ts +9 -0
  67. package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
  68. package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
  69. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
  70. package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
  71. package/packages/driver-chrome-relay/src/index.ts +26 -0
  72. package/packages/driver-managed/src/index.ts +22 -0
  73. package/packages/driver-managed/src/managed-driver.test.ts +59 -0
  74. package/packages/driver-managed/src/managed-driver.ts +125 -0
  75. package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
  76. package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
  77. package/packages/driver-remote-cdp/src/index.ts +19 -0
  78. package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
  79. package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
  80. package/packages/protocol/src/envelope.test.ts +25 -0
  81. package/packages/protocol/src/envelope.ts +31 -0
  82. package/packages/protocol/src/errors.test.ts +17 -0
  83. package/packages/protocol/src/errors.ts +11 -0
  84. package/packages/protocol/src/index.ts +3 -0
  85. package/packages/protocol/src/tools.ts +3 -0
  86. package/packages/transport-mcp-stdio/src/index.ts +3 -0
  87. package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
  88. package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
  89. package/packages/transport-mcp-stdio/src/server.ts +183 -0
  90. package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
  91. package/scripts/smoke.ps1 +127 -0
@@ -0,0 +1,1531 @@
1
+ import {
2
+ DriverRegistry,
3
+ SessionStore,
4
+ type BrowserDriver,
5
+ type BrowserDriverScreenshot
6
+ } from "../../../packages/core/src";
7
+ import {
8
+ createChromeRelayDriver,
9
+ createChromeRelayExtensionRuntime
10
+ } from "../../../packages/driver-chrome-relay/src";
11
+ import {
12
+ createManagedDriver,
13
+ createManagedLocalDriver,
14
+ type ManagedLocalBrowserName,
15
+ type ManagedLocalTelemetryDriverExtensions
16
+ } from "../../../packages/driver-managed/src";
17
+ import { createRemoteCdpDriver } from "../../../packages/driver-remote-cdp/src";
18
+ import { ErrorCode, createErr, createOk, type ToolResponse } from "../../../packages/protocol/src";
19
+ import {
20
+ buildToolMap,
21
+ createMcpStdioServer,
22
+ type McpStdioServer,
23
+ type ToolCallArgs,
24
+ type ToolMap
25
+ } from "../../../packages/transport-mcp-stdio/src";
26
+ import { win32 as windowsPath } from "node:path";
27
+ import { createChromeRelayExtensionBridge } from "./chrome-relay-extension-bridge";
28
+
29
+ type CapabilityScope = "read" | "act" | "upload" | "download";
30
+ type ChromeRelayMode = "cdp" | "extension";
31
+
32
+ export type BrowserdConfig = {
33
+ chromeRelayUrl: string;
34
+ chromeRelayMode: ChromeRelayMode;
35
+ chromeRelayExtensionToken?: string;
36
+ chromeRelayExtensionRequestTimeoutMs: number;
37
+ remoteCdpUrl: string;
38
+ defaultDriver: string;
39
+ managedLocalEnabled: boolean;
40
+ uploadRoot?: string;
41
+ downloadRoot?: string;
42
+ authToken?: string;
43
+ authScopes: CapabilityScope[];
44
+ managedLocalLaunch: {
45
+ browserName: ManagedLocalBrowserName;
46
+ headless: boolean;
47
+ channel?: string;
48
+ executablePath?: string;
49
+ launchTimeoutMs?: number;
50
+ args: string[];
51
+ };
52
+ };
53
+
54
+ const DEFAULT_DRIVER_KEY = "managed";
55
+ const MANAGED_LOCAL_DRIVER_KEY = "managed-local";
56
+ const DEFAULT_MANAGED_LOCAL_BROWSER_NAME: ManagedLocalBrowserName = "chromium";
57
+ const DEFAULT_MANAGED_LOCAL_HEADLESS = true;
58
+ const DEFAULT_CHROME_RELAY_MODE: ChromeRelayMode = "cdp";
59
+ const DEFAULT_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS = 5_000;
60
+ const DEFAULT_NETWORK_WAIT_TIMEOUT_MS = 10_000;
61
+ const DEFAULT_NETWORK_WAIT_POLL_MS = 200;
62
+ const ALL_CAPABILITY_SCOPES: readonly CapabilityScope[] = ["read", "act", "upload", "download"];
63
+ const TOOL_SCOPE_BY_NAME: Readonly<Record<string, CapabilityScope>> = {
64
+ "browser.status": "read",
65
+ "browser.profile.list": "read",
66
+ "browser.profile.use": "act",
67
+ "browser.tab.list": "read",
68
+ "browser.tab.open": "act",
69
+ "browser.tab.focus": "act",
70
+ "browser.tab.close": "act",
71
+ "browser.snapshot": "read",
72
+ "browser.screenshot": "read",
73
+ "browser.dom.query": "read",
74
+ "browser.dom.queryAll": "read",
75
+ "browser.element.screenshot": "read",
76
+ "browser.a11y.snapshot": "read",
77
+ "browser.act": "act",
78
+ "browser.upload.arm": "upload",
79
+ "browser.dialog.arm": "act",
80
+ "browser.download.wait": "download",
81
+ "browser.download.trigger": "download",
82
+ "browser.network.waitFor": "read",
83
+ "browser.cookie.get": "read",
84
+ "browser.cookie.set": "act",
85
+ "browser.cookie.clear": "act",
86
+ "browser.storage.get": "read",
87
+ "browser.storage.set": "act",
88
+ "browser.frame.list": "read",
89
+ "browser.frame.snapshot": "read",
90
+ "browser.console.list": "read",
91
+ "browser.network.responseBody": "read"
92
+ };
93
+
94
+ function resolveNonEmptyString(value: string | undefined): string | undefined {
95
+ if (value === undefined) {
96
+ return undefined;
97
+ }
98
+
99
+ const trimmedValue = value.trim();
100
+ return trimmedValue.length === 0 ? undefined : trimmedValue;
101
+ }
102
+
103
+ function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean {
104
+ if (value === undefined) {
105
+ return fallback;
106
+ }
107
+
108
+ const normalizedValue = value.trim().toLowerCase();
109
+ if (normalizedValue === "1" || normalizedValue === "true" || normalizedValue === "yes" || normalizedValue === "on") {
110
+ return true;
111
+ }
112
+
113
+ if (normalizedValue === "0" || normalizedValue === "false" || normalizedValue === "no" || normalizedValue === "off") {
114
+ return false;
115
+ }
116
+
117
+ return fallback;
118
+ }
119
+
120
+ function parseOptionalNumber(value: string | undefined): number | undefined {
121
+ const parsedValue = resolveNonEmptyString(value);
122
+ if (parsedValue === undefined) {
123
+ return undefined;
124
+ }
125
+
126
+ const parsedNumber = Number(parsedValue);
127
+ if (!Number.isFinite(parsedNumber) || parsedNumber < 0) {
128
+ return undefined;
129
+ }
130
+
131
+ return parsedNumber;
132
+ }
133
+
134
+ function parseCsv(value: string | undefined): string[] {
135
+ const rawValue = resolveNonEmptyString(value);
136
+ if (rawValue === undefined) {
137
+ return [];
138
+ }
139
+
140
+ return rawValue
141
+ .split(",")
142
+ .map((item) => item.trim())
143
+ .filter((item) => item.length > 0);
144
+ }
145
+
146
+ function isCapabilityScope(value: string): value is CapabilityScope {
147
+ return (
148
+ value === "read" ||
149
+ value === "act" ||
150
+ value === "upload" ||
151
+ value === "download"
152
+ );
153
+ }
154
+
155
+ function parseCapabilityScopes(value: string | undefined): CapabilityScope[] {
156
+ const parsed = parseCsv(value).map((item) => item.toLowerCase());
157
+ if (parsed.length === 0) {
158
+ return [...ALL_CAPABILITY_SCOPES];
159
+ }
160
+
161
+ const scopes: CapabilityScope[] = [];
162
+ for (const item of parsed) {
163
+ if (!isCapabilityScope(item) || scopes.includes(item)) {
164
+ continue;
165
+ }
166
+
167
+ scopes.push(item);
168
+ }
169
+
170
+ return scopes;
171
+ }
172
+
173
+ function parseManagedLocalBrowserName(value: string | undefined): ManagedLocalBrowserName {
174
+ const normalizedValue = value?.trim().toLowerCase();
175
+ if (normalizedValue === "firefox" || normalizedValue === "webkit" || normalizedValue === "chromium") {
176
+ return normalizedValue;
177
+ }
178
+
179
+ return DEFAULT_MANAGED_LOCAL_BROWSER_NAME;
180
+ }
181
+
182
+ function parseChromeRelayMode(value: string | undefined): ChromeRelayMode {
183
+ const normalizedValue = value?.trim().toLowerCase();
184
+ return normalizedValue === "extension" ? "extension" : DEFAULT_CHROME_RELAY_MODE;
185
+ }
186
+
187
+ export type BrowserdContainer = {
188
+ config: BrowserdConfig;
189
+ drivers: Map<string, BrowserDriver>;
190
+ driverRegistry: DriverRegistry;
191
+ sessions: SessionStore;
192
+ mcpServer: McpStdioServer;
193
+ close(): void;
194
+ };
195
+
196
+ export function loadBrowserdConfig(
197
+ env: Record<string, string | undefined> = process.env
198
+ ): BrowserdConfig {
199
+ const managedLocalEnabled = parseBooleanFlag(env.BROWSERD_MANAGED_LOCAL_ENABLED, true);
200
+ const chromeRelayMode = parseChromeRelayMode(env.BROWSERD_CHROME_RELAY_MODE);
201
+ const chromeRelayExtensionToken = resolveNonEmptyString(env.BROWSERD_CHROME_RELAY_EXTENSION_TOKEN);
202
+ if (chromeRelayMode === "extension" && chromeRelayExtensionToken === undefined) {
203
+ throw new Error(
204
+ "BROWSERD_CHROME_RELAY_EXTENSION_TOKEN is required when BROWSERD_CHROME_RELAY_MODE=extension."
205
+ );
206
+ }
207
+
208
+ const defaultDriver =
209
+ resolveNonEmptyString(env.BROWSERD_DEFAULT_DRIVER) ??
210
+ (managedLocalEnabled ? MANAGED_LOCAL_DRIVER_KEY : DEFAULT_DRIVER_KEY);
211
+
212
+ return {
213
+ chromeRelayUrl: env.BROWSERD_CHROME_RELAY_URL ?? "http://127.0.0.1:9223",
214
+ chromeRelayMode,
215
+ chromeRelayExtensionToken,
216
+ chromeRelayExtensionRequestTimeoutMs:
217
+ parseOptionalNumber(env.BROWSERD_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS) ??
218
+ DEFAULT_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS,
219
+ remoteCdpUrl:
220
+ env.BROWSERD_REMOTE_CDP_URL ?? "http://127.0.0.1:9222/devtools/browser/default",
221
+ defaultDriver,
222
+ managedLocalEnabled,
223
+ uploadRoot: resolveNonEmptyString(env.BROWSERD_UPLOAD_ROOT),
224
+ downloadRoot: resolveNonEmptyString(env.BROWSERD_DOWNLOAD_ROOT),
225
+ authToken: resolveNonEmptyString(env.BROWSERD_AUTH_TOKEN),
226
+ authScopes: parseCapabilityScopes(env.BROWSERD_AUTH_SCOPES),
227
+ managedLocalLaunch: {
228
+ browserName: parseManagedLocalBrowserName(env.BROWSERD_MANAGED_LOCAL_BROWSER),
229
+ headless: parseBooleanFlag(env.BROWSERD_MANAGED_LOCAL_HEADLESS, DEFAULT_MANAGED_LOCAL_HEADLESS),
230
+ channel: resolveNonEmptyString(env.BROWSERD_MANAGED_LOCAL_CHANNEL),
231
+ executablePath: resolveNonEmptyString(env.BROWSERD_MANAGED_LOCAL_EXECUTABLE_PATH),
232
+ launchTimeoutMs: parseOptionalNumber(env.BROWSERD_MANAGED_LOCAL_LAUNCH_TIMEOUT_MS),
233
+ args: parseCsv(env.BROWSERD_MANAGED_LOCAL_ARGS)
234
+ }
235
+ };
236
+ }
237
+
238
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
241
+
242
+ function isStringArray(value: unknown): value is string[] {
243
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
244
+ }
245
+
246
+ function toErrorMessage(error: unknown): string {
247
+ return error instanceof Error ? error.message : "Unexpected browserd failure.";
248
+ }
249
+
250
+ function mapDriverError(error: unknown): ToolResponse<never> {
251
+ const message = toErrorMessage(error);
252
+ if (message.startsWith("Unknown targetId:")) {
253
+ return createErr(ErrorCode.E_NOT_FOUND, message);
254
+ }
255
+
256
+ if (message.startsWith("Unknown driver:")) {
257
+ return createErr(ErrorCode.E_NOT_FOUND, message);
258
+ }
259
+
260
+ return createErr(ErrorCode.E_INTERNAL, message);
261
+ }
262
+
263
+ function requireStringArg(args: ToolCallArgs, key: string): ToolResponse<string> {
264
+ const value = args[key];
265
+ if (typeof value !== "string" || value.trim().length === 0) {
266
+ return createErr(ErrorCode.E_INVALID_ARG, `${key} is required and must be a non-empty string.`);
267
+ }
268
+
269
+ return createOk(value.trim());
270
+ }
271
+
272
+ function resolveTargetId(args: ToolCallArgs, sessions: SessionStore): ToolResponse<string> {
273
+ const explicit = requireStringArg(args, "targetId");
274
+ if (explicit.ok) {
275
+ sessions.useTarget(args.sessionId, explicit.data);
276
+ return explicit;
277
+ }
278
+
279
+ const sessionTarget = sessions.get(args.sessionId)?.targetId;
280
+ if (sessionTarget === undefined) {
281
+ return explicit;
282
+ }
283
+
284
+ return createOk(sessionTarget);
285
+ }
286
+
287
+ function resolveAction(args: ToolCallArgs): ToolResponse<{ type: string; payload?: Record<string, unknown> }> {
288
+ const rawAction = args.action;
289
+ if (!isObjectRecord(rawAction)) {
290
+ return createErr(ErrorCode.E_INVALID_ARG, "action is required and must be an object.");
291
+ }
292
+
293
+ const actionType = rawAction.type;
294
+ if (typeof actionType !== "string" || actionType.trim().length === 0) {
295
+ return createErr(ErrorCode.E_INVALID_ARG, "action.type is required and must be a non-empty string.");
296
+ }
297
+
298
+ const rawPayload = rawAction.payload;
299
+ if (rawPayload !== undefined && !isObjectRecord(rawPayload)) {
300
+ return createErr(ErrorCode.E_INVALID_ARG, "action.payload must be an object when provided.");
301
+ }
302
+
303
+ return createOk({
304
+ type: actionType.trim(),
305
+ payload: rawPayload as Record<string, unknown> | undefined
306
+ });
307
+ }
308
+
309
+ function resolveFiles(args: ToolCallArgs): ToolResponse<string[]> {
310
+ const files = args.files;
311
+ if (!isStringArray(files)) {
312
+ return createErr(ErrorCode.E_INVALID_ARG, "files is required and must be a string array.");
313
+ }
314
+
315
+ const normalizedFiles = files.map((file) => file.trim());
316
+ if (normalizedFiles.some((file) => file.length === 0)) {
317
+ return createErr(
318
+ ErrorCode.E_INVALID_ARG,
319
+ "files must contain only non-empty string paths."
320
+ );
321
+ }
322
+
323
+ return createOk(normalizedFiles);
324
+ }
325
+
326
+ function resolveRequestId(args: ToolCallArgs): ToolResponse<string> {
327
+ return requireStringArg(args, "requestId");
328
+ }
329
+
330
+ function resolveOptionalPathArg(args: ToolCallArgs, key: string): ToolResponse<string | undefined> {
331
+ const value = args[key];
332
+ if (value === undefined) {
333
+ return createOk(undefined);
334
+ }
335
+
336
+ if (typeof value !== "string" || value.trim().length === 0) {
337
+ return createErr(
338
+ ErrorCode.E_INVALID_ARG,
339
+ `${key} must be a non-empty string when provided.`
340
+ );
341
+ }
342
+
343
+ return createOk(value.trim());
344
+ }
345
+
346
+ function resolveDriver(
347
+ args: ToolCallArgs,
348
+ drivers: Map<string, BrowserDriver>,
349
+ sessions: SessionStore,
350
+ defaultDriverKey: string
351
+ ): ToolResponse<{ driverKey: string; driver: BrowserDriver }> {
352
+ const requestedProfile = args.profile;
353
+ if (requestedProfile !== undefined) {
354
+ const requestedDriver = drivers.get(requestedProfile);
355
+ if (requestedDriver === undefined) {
356
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown driver profile: ${requestedProfile}`);
357
+ }
358
+
359
+ sessions.useProfile(args.sessionId, requestedProfile);
360
+ return createOk({
361
+ driverKey: requestedProfile,
362
+ driver: requestedDriver
363
+ });
364
+ }
365
+
366
+ const sessionProfile = sessions.get(args.sessionId)?.profile;
367
+ if (sessionProfile !== undefined) {
368
+ const sessionDriver = drivers.get(sessionProfile);
369
+ if (sessionDriver !== undefined) {
370
+ return createOk({
371
+ driverKey: sessionProfile,
372
+ driver: sessionDriver
373
+ });
374
+ }
375
+ }
376
+
377
+ const defaultDriver = drivers.get(defaultDriverKey);
378
+ if (defaultDriver === undefined) {
379
+ return createErr(ErrorCode.E_DRIVER_UNAVAILABLE, `Default driver is not registered: ${defaultDriverKey}`);
380
+ }
381
+
382
+ sessions.useProfile(args.sessionId, defaultDriverKey);
383
+ return createOk({
384
+ driverKey: defaultDriverKey,
385
+ driver: defaultDriver
386
+ });
387
+ }
388
+
389
+ async function runWithDriver<TData>(
390
+ args: ToolCallArgs,
391
+ drivers: Map<string, BrowserDriver>,
392
+ sessions: SessionStore,
393
+ defaultDriverKey: string,
394
+ operation: (
395
+ driver: BrowserDriver,
396
+ driverKey: string
397
+ ) => Promise<ToolResponse<TData>> | ToolResponse<TData>
398
+ ): Promise<ToolResponse<TData>> {
399
+ const resolvedDriver = resolveDriver(args, drivers, sessions, defaultDriverKey);
400
+ if (!resolvedDriver.ok) {
401
+ return resolvedDriver;
402
+ }
403
+
404
+ try {
405
+ return await operation(resolvedDriver.data.driver, resolvedDriver.data.driverKey);
406
+ } catch (error) {
407
+ return mapDriverError(error);
408
+ }
409
+ }
410
+
411
+ function isTargetKnownSnapshot(snapshot: unknown): boolean {
412
+ if (!isObjectRecord(snapshot)) {
413
+ return false;
414
+ }
415
+
416
+ return snapshot.hasTarget === true;
417
+ }
418
+
419
+ function hasConsoleEntriesGetter(
420
+ driver: BrowserDriver
421
+ ): driver is BrowserDriver & {
422
+ getConsoleEntries: NonNullable<ManagedLocalTelemetryDriverExtensions["getConsoleEntries"]>;
423
+ } {
424
+ return typeof (driver as ManagedLocalTelemetryDriverExtensions).getConsoleEntries === "function";
425
+ }
426
+
427
+ function hasNetworkResponseBodyGetter(
428
+ driver: BrowserDriver
429
+ ): driver is BrowserDriver & {
430
+ getNetworkResponseBody: NonNullable<ManagedLocalTelemetryDriverExtensions["getNetworkResponseBody"]>;
431
+ } {
432
+ return typeof (driver as ManagedLocalTelemetryDriverExtensions).getNetworkResponseBody === "function";
433
+ }
434
+
435
+ function hasScreenshotGetter(
436
+ driver: BrowserDriver
437
+ ): driver is BrowserDriver & BrowserDriverScreenshot {
438
+ return typeof (driver as Partial<BrowserDriverScreenshot>).screenshot === "function";
439
+ }
440
+
441
+ function resolveOptionalStringArg(args: ToolCallArgs, key: string): ToolResponse<string | undefined> {
442
+ const value = args[key];
443
+ if (value === undefined) {
444
+ return createOk(undefined);
445
+ }
446
+
447
+ if (typeof value !== "string") {
448
+ return createErr(
449
+ ErrorCode.E_INVALID_ARG,
450
+ `${key} must be a string when provided.`
451
+ );
452
+ }
453
+
454
+ const trimmedValue = value.trim();
455
+ return trimmedValue.length === 0 ? createOk(undefined) : createOk(trimmedValue);
456
+ }
457
+
458
+ function resolveOptionalNumberArg(args: ToolCallArgs, key: string): ToolResponse<number | undefined> {
459
+ const value = args[key];
460
+ if (value === undefined) {
461
+ return createOk(undefined);
462
+ }
463
+
464
+ if (typeof value !== "number" || !Number.isFinite(value)) {
465
+ return createErr(
466
+ ErrorCode.E_INVALID_ARG,
467
+ `${key} must be a finite number when provided.`
468
+ );
469
+ }
470
+
471
+ return createOk(value);
472
+ }
473
+
474
+ function resolveOptionalIntegerArg(
475
+ args: ToolCallArgs,
476
+ key: string,
477
+ minimum: number
478
+ ): ToolResponse<number | undefined> {
479
+ const resolved = resolveOptionalNumberArg(args, key);
480
+ if (!resolved.ok || resolved.data === undefined) {
481
+ return resolved;
482
+ }
483
+
484
+ if (!Number.isInteger(resolved.data) || resolved.data < minimum) {
485
+ return createErr(
486
+ ErrorCode.E_INVALID_ARG,
487
+ `${key} must be an integer greater than or equal to ${minimum}.`
488
+ );
489
+ }
490
+
491
+ return resolved;
492
+ }
493
+
494
+ function resolveDriverActionData(
495
+ actionResult: unknown,
496
+ actionType: string
497
+ ): ToolResponse<Record<string, unknown>> {
498
+ if (!isObjectRecord(actionResult)) {
499
+ return createErr(ErrorCode.E_INTERNAL, `Invalid action result payload for ${actionType}.`);
500
+ }
501
+
502
+ if (actionResult.targetKnown === false) {
503
+ return createErr(
504
+ ErrorCode.E_NOT_FOUND,
505
+ typeof actionResult.error === "string"
506
+ ? actionResult.error
507
+ : `Unknown target for action ${actionType}.`
508
+ );
509
+ }
510
+
511
+ if (actionResult.ok !== true) {
512
+ return createErr(
513
+ ErrorCode.E_INTERNAL,
514
+ typeof actionResult.error === "string"
515
+ ? actionResult.error
516
+ : `Driver action failed: ${actionType}`
517
+ );
518
+ }
519
+
520
+ if (actionResult.executed !== true) {
521
+ return createErr(
522
+ ErrorCode.E_DRIVER_UNAVAILABLE,
523
+ `Driver does not support action: ${actionType}`
524
+ );
525
+ }
526
+
527
+ const data = actionResult.data;
528
+ if (data === undefined) {
529
+ return createOk({});
530
+ }
531
+
532
+ if (!isObjectRecord(data)) {
533
+ return createErr(ErrorCode.E_INTERNAL, `Invalid action data payload for ${actionType}.`);
534
+ }
535
+
536
+ return createOk(data);
537
+ }
538
+
539
+ type NetworkRequestSummary = {
540
+ requestId: string;
541
+ url: string;
542
+ method?: string;
543
+ status?: number;
544
+ resourceType?: string;
545
+ timestamp?: string;
546
+ };
547
+
548
+ function readNetworkRequestSummaries(snapshot: unknown): NetworkRequestSummary[] {
549
+ if (!isObjectRecord(snapshot)) {
550
+ return [];
551
+ }
552
+
553
+ const rawSummaries = snapshot.requestSummaries;
554
+ if (!Array.isArray(rawSummaries)) {
555
+ return [];
556
+ }
557
+
558
+ const summaries: NetworkRequestSummary[] = [];
559
+ for (const rawSummary of rawSummaries) {
560
+ if (!isObjectRecord(rawSummary)) {
561
+ continue;
562
+ }
563
+
564
+ const requestId = rawSummary.requestId;
565
+ const url = rawSummary.url;
566
+ if (typeof requestId !== "string" || typeof url !== "string") {
567
+ continue;
568
+ }
569
+
570
+ const method =
571
+ typeof rawSummary.method === "string" && rawSummary.method.trim().length > 0
572
+ ? rawSummary.method
573
+ : undefined;
574
+ const status =
575
+ typeof rawSummary.status === "number" && Number.isFinite(rawSummary.status)
576
+ ? rawSummary.status
577
+ : undefined;
578
+ const resourceType =
579
+ typeof rawSummary.resourceType === "string" && rawSummary.resourceType.trim().length > 0
580
+ ? rawSummary.resourceType
581
+ : undefined;
582
+ const timestamp =
583
+ typeof rawSummary.timestamp === "string" && rawSummary.timestamp.trim().length > 0
584
+ ? rawSummary.timestamp
585
+ : undefined;
586
+
587
+ summaries.push({
588
+ requestId,
589
+ url,
590
+ ...(method !== undefined ? { method } : {}),
591
+ ...(status !== undefined ? { status } : {}),
592
+ ...(resourceType !== undefined ? { resourceType } : {}),
593
+ ...(timestamp !== undefined ? { timestamp } : {})
594
+ });
595
+ }
596
+
597
+ return summaries;
598
+ }
599
+
600
+ function delay(ms: number): Promise<void> {
601
+ return new Promise((resolve) => {
602
+ setTimeout(resolve, ms);
603
+ });
604
+ }
605
+
606
+ function resolveRequiredScope(toolName: string): CapabilityScope {
607
+ return TOOL_SCOPE_BY_NAME[toolName] ?? "read";
608
+ }
609
+
610
+ function resolveProvidedAuthToken(args: ToolCallArgs): string | undefined {
611
+ const rawToken = args.authToken;
612
+ if (typeof rawToken !== "string") {
613
+ return undefined;
614
+ }
615
+
616
+ const trimmedToken = rawToken.trim();
617
+ return trimmedToken.length === 0 ? undefined : trimmedToken;
618
+ }
619
+
620
+ function authorizeToolCall(
621
+ args: ToolCallArgs,
622
+ toolName: string,
623
+ configuredToken: string | undefined,
624
+ allowedScopes: ReadonlySet<CapabilityScope>
625
+ ): ToolResponse<null> {
626
+ if (configuredToken !== undefined) {
627
+ const providedToken = resolveProvidedAuthToken(args);
628
+ if (providedToken === undefined || providedToken !== configuredToken) {
629
+ return createErr(ErrorCode.E_PERMISSION, "Invalid auth token.");
630
+ }
631
+ }
632
+
633
+ const requiredScope = resolveRequiredScope(toolName);
634
+ if (!allowedScopes.has(requiredScope)) {
635
+ return createErr(
636
+ ErrorCode.E_PERMISSION,
637
+ `Tool scope is not allowed: ${toolName} requires ${requiredScope}.`
638
+ );
639
+ }
640
+
641
+ return createOk(null);
642
+ }
643
+
644
+ function resolveWindowsPathWithinRoot(
645
+ inputPath: string,
646
+ rootPath: string,
647
+ context: "upload" | "download"
648
+ ): ToolResponse<string> {
649
+ const resolvedRoot = windowsPath.resolve(rootPath);
650
+ const resolvedPath = windowsPath.isAbsolute(inputPath)
651
+ ? windowsPath.resolve(inputPath)
652
+ : windowsPath.resolve(resolvedRoot, inputPath);
653
+ const relativePath = windowsPath.relative(
654
+ resolvedRoot.toLowerCase(),
655
+ resolvedPath.toLowerCase()
656
+ );
657
+ const isInRoot =
658
+ relativePath === "" ||
659
+ (!relativePath.startsWith("..") && !windowsPath.isAbsolute(relativePath));
660
+ if (!isInRoot) {
661
+ return createErr(
662
+ ErrorCode.E_PERMISSION,
663
+ `${context} path is outside configured allowlist root: ${inputPath}`
664
+ );
665
+ }
666
+
667
+ return createOk(resolvedPath);
668
+ }
669
+
670
+ function applyUploadRoot(files: string[], uploadRoot: string | undefined): ToolResponse<string[]> {
671
+ if (uploadRoot === undefined) {
672
+ return createErr(
673
+ ErrorCode.E_PERMISSION,
674
+ "Upload root is not configured. Set BROWSERD_UPLOAD_ROOT to enable browser.upload.arm."
675
+ );
676
+ }
677
+
678
+ const rootedFiles: string[] = [];
679
+ for (const file of files) {
680
+ const rootedPath = resolveWindowsPathWithinRoot(file, uploadRoot, "upload");
681
+ if (!rootedPath.ok) {
682
+ return rootedPath;
683
+ }
684
+
685
+ rootedFiles.push(rootedPath.data);
686
+ }
687
+
688
+ return createOk(rootedFiles);
689
+ }
690
+
691
+ function normalizeDownloadPayload(
692
+ rawDownload: unknown,
693
+ requestedPath: string | undefined,
694
+ downloadRoot: string | undefined
695
+ ): ToolResponse<Record<string, unknown>> {
696
+ if (downloadRoot === undefined) {
697
+ return createErr(
698
+ ErrorCode.E_PERMISSION,
699
+ "Download root is not configured. Set BROWSERD_DOWNLOAD_ROOT to enable browser.download.wait."
700
+ );
701
+ }
702
+
703
+ if (!isObjectRecord(rawDownload)) {
704
+ return createErr(ErrorCode.E_INTERNAL, "Invalid driver download payload.");
705
+ }
706
+
707
+ const download = { ...rawDownload };
708
+ const downloadPath =
709
+ requestedPath ??
710
+ (typeof download.path === "string" && download.path.trim().length > 0
711
+ ? download.path.trim()
712
+ : undefined);
713
+ if (downloadPath === undefined) {
714
+ return createErr(
715
+ ErrorCode.E_INTERNAL,
716
+ "Driver download payload is missing path required for allowlist enforcement."
717
+ );
718
+ }
719
+
720
+ const rootedPath = resolveWindowsPathWithinRoot(downloadPath, downloadRoot, "download");
721
+ if (!rootedPath.ok) {
722
+ return rootedPath;
723
+ }
724
+
725
+ return createOk({
726
+ ...download,
727
+ path: rootedPath.data
728
+ });
729
+ }
730
+
731
+ function createBrowserdToolMap(
732
+ drivers: Map<string, BrowserDriver>,
733
+ sessions: SessionStore,
734
+ defaultDriverKey: string,
735
+ config: BrowserdConfig
736
+ ): ToolMap {
737
+ const toolMap = buildToolMap();
738
+ const allowedScopes = new Set<CapabilityScope>(config.authScopes);
739
+ const runWithDefaultDriver = <TData>(
740
+ args: ToolCallArgs,
741
+ operation: (
742
+ driver: BrowserDriver,
743
+ driverKey: string
744
+ ) => Promise<ToolResponse<TData>> | ToolResponse<TData>
745
+ ): Promise<ToolResponse<TData>> =>
746
+ runWithDriver(args, drivers, sessions, defaultDriverKey, operation);
747
+
748
+ const runStructuredAction = async (
749
+ args: ToolCallArgs,
750
+ actionType: string,
751
+ payload: Record<string, unknown>
752
+ ): Promise<
753
+ ToolResponse<{
754
+ driver: string;
755
+ targetId: string;
756
+ data: Record<string, unknown>;
757
+ }>
758
+ > =>
759
+ await runWithDefaultDriver(args, async (driver, driverKey) => {
760
+ const targetId = resolveTargetId(args, sessions);
761
+ if (!targetId.ok) {
762
+ return targetId;
763
+ }
764
+
765
+ const actionResult = await driver.act(
766
+ {
767
+ type: actionType,
768
+ payload
769
+ },
770
+ targetId.data
771
+ );
772
+ const resolvedData = resolveDriverActionData(actionResult, actionType);
773
+ if (!resolvedData.ok) {
774
+ return resolvedData;
775
+ }
776
+
777
+ return createOk({
778
+ driver: driverKey,
779
+ targetId: targetId.data,
780
+ data: resolvedData.data
781
+ });
782
+ });
783
+
784
+ toolMap.set("browser.status", async (args) => {
785
+ const resolvedDriver = resolveDriver(args, drivers, sessions, defaultDriverKey);
786
+ if (!resolvedDriver.ok) {
787
+ return resolvedDriver;
788
+ }
789
+
790
+ try {
791
+ const status = await resolvedDriver.data.driver.status();
792
+ return createOk({
793
+ kind: "browserd",
794
+ ready: true,
795
+ driver: resolvedDriver.data.driverKey,
796
+ drivers: [...drivers.keys()].sort(),
797
+ status
798
+ });
799
+ } catch (error) {
800
+ return mapDriverError(error);
801
+ }
802
+ });
803
+
804
+ toolMap.set("browser.profile.use", async (args) => {
805
+ const requestedProfile = requireStringArg(args, "profile");
806
+ if (!requestedProfile.ok) {
807
+ return requestedProfile;
808
+ }
809
+
810
+ if (!drivers.has(requestedProfile.data)) {
811
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown driver profile: ${requestedProfile.data}`);
812
+ }
813
+
814
+ sessions.useProfile(args.sessionId, requestedProfile.data);
815
+ return createOk({
816
+ profile: requestedProfile.data
817
+ });
818
+ });
819
+
820
+ toolMap.set("browser.profile.list", async (args) =>
821
+ runWithDefaultDriver(args, async (driver, driverKey) => {
822
+ const profiles = await driver.listProfiles();
823
+ return createOk({
824
+ driver: driverKey,
825
+ profiles
826
+ });
827
+ })
828
+ );
829
+
830
+ toolMap.set("browser.tab.list", async (args) =>
831
+ runWithDefaultDriver(args, async (driver, driverKey) => {
832
+ const tabs = await driver.listTabs();
833
+ return createOk({
834
+ driver: driverKey,
835
+ tabs
836
+ });
837
+ })
838
+ );
839
+
840
+ toolMap.set("browser.tab.open", async (args) =>
841
+ runWithDefaultDriver(args, async (driver, driverKey) => {
842
+ const url = requireStringArg(args, "url");
843
+ if (!url.ok) {
844
+ return url;
845
+ }
846
+
847
+ const targetId = await driver.openTab(url.data);
848
+ sessions.useTarget(args.sessionId, targetId);
849
+ return createOk({
850
+ driver: driverKey,
851
+ targetId
852
+ });
853
+ })
854
+ );
855
+
856
+ toolMap.set("browser.tab.focus", async (args) =>
857
+ runWithDefaultDriver(args, async (driver, driverKey) => {
858
+ const targetId = resolveTargetId(args, sessions);
859
+ if (!targetId.ok) {
860
+ return targetId;
861
+ }
862
+
863
+ await driver.focusTab(targetId.data);
864
+ sessions.useTarget(args.sessionId, targetId.data);
865
+ return createOk({
866
+ driver: driverKey,
867
+ targetId: targetId.data,
868
+ focused: true
869
+ });
870
+ })
871
+ );
872
+
873
+ toolMap.set("browser.tab.close", async (args) =>
874
+ runWithDefaultDriver(args, async (driver, driverKey) => {
875
+ const targetId = resolveTargetId(args, sessions);
876
+ if (!targetId.ok) {
877
+ return targetId;
878
+ }
879
+
880
+ await driver.closeTab(targetId.data);
881
+ return createOk({
882
+ driver: driverKey,
883
+ targetId: targetId.data,
884
+ closed: true
885
+ });
886
+ })
887
+ );
888
+
889
+ toolMap.set("browser.snapshot", async (args) =>
890
+ runWithDefaultDriver(args, async (driver, driverKey) => {
891
+ const targetId = resolveTargetId(args, sessions);
892
+ if (!targetId.ok) {
893
+ return targetId;
894
+ }
895
+
896
+ const snapshot = await driver.snapshot(targetId.data);
897
+ return createOk({
898
+ driver: driverKey,
899
+ targetId: targetId.data,
900
+ snapshot
901
+ });
902
+ })
903
+ );
904
+
905
+ toolMap.set("browser.screenshot", async (args) =>
906
+ runWithDefaultDriver(args, async (driver, driverKey) => {
907
+ const targetId = resolveTargetId(args, sessions);
908
+ if (!targetId.ok) {
909
+ return targetId;
910
+ }
911
+
912
+ if (!hasScreenshotGetter(driver)) {
913
+ return createErr(
914
+ ErrorCode.E_DRIVER_UNAVAILABLE,
915
+ `Driver does not support screenshots: ${driverKey}`
916
+ );
917
+ }
918
+
919
+ const screenshot = await driver.screenshot(targetId.data);
920
+ return createOk({
921
+ driver: driverKey,
922
+ targetId: targetId.data,
923
+ screenshot
924
+ });
925
+ })
926
+ );
927
+
928
+ toolMap.set("browser.dom.query", async (args) => {
929
+ const selector = requireStringArg(args, "selector");
930
+ if (!selector.ok) {
931
+ return selector;
932
+ }
933
+
934
+ const result = await runStructuredAction(args, "domQuery", {
935
+ selector: selector.data
936
+ });
937
+ if (!result.ok) {
938
+ return result;
939
+ }
940
+
941
+ return createOk({
942
+ driver: result.data.driver,
943
+ targetId: result.data.targetId,
944
+ query: result.data.data
945
+ });
946
+ });
947
+
948
+ toolMap.set("browser.dom.queryAll", async (args) => {
949
+ const selector = requireStringArg(args, "selector");
950
+ if (!selector.ok) {
951
+ return selector;
952
+ }
953
+
954
+ const result = await runStructuredAction(args, "domQueryAll", {
955
+ selector: selector.data
956
+ });
957
+ if (!result.ok) {
958
+ return result;
959
+ }
960
+
961
+ return createOk({
962
+ driver: result.data.driver,
963
+ targetId: result.data.targetId,
964
+ query: result.data.data
965
+ });
966
+ });
967
+
968
+ toolMap.set("browser.element.screenshot", async (args) => {
969
+ const selector = requireStringArg(args, "selector");
970
+ if (!selector.ok) {
971
+ return selector;
972
+ }
973
+
974
+ const result = await runStructuredAction(args, "elementScreenshot", {
975
+ selector: selector.data
976
+ });
977
+ if (!result.ok) {
978
+ return result;
979
+ }
980
+
981
+ return createOk({
982
+ driver: result.data.driver,
983
+ targetId: result.data.targetId,
984
+ screenshot: result.data.data
985
+ });
986
+ });
987
+
988
+ toolMap.set("browser.a11y.snapshot", async (args) => {
989
+ const selector = resolveOptionalStringArg(args, "selector");
990
+ if (!selector.ok) {
991
+ return selector;
992
+ }
993
+
994
+ const result = await runStructuredAction(args, "a11ySnapshot", {
995
+ ...(selector.data !== undefined ? { selector: selector.data } : {})
996
+ });
997
+ if (!result.ok) {
998
+ return result;
999
+ }
1000
+
1001
+ return createOk({
1002
+ driver: result.data.driver,
1003
+ targetId: result.data.targetId,
1004
+ snapshot: result.data.data
1005
+ });
1006
+ });
1007
+
1008
+ toolMap.set("browser.cookie.get", async (args) => {
1009
+ const name = resolveOptionalStringArg(args, "name");
1010
+ if (!name.ok) {
1011
+ return name;
1012
+ }
1013
+
1014
+ const result = await runStructuredAction(args, "cookieGet", {
1015
+ ...(name.data !== undefined ? { name: name.data } : {})
1016
+ });
1017
+ if (!result.ok) {
1018
+ return result;
1019
+ }
1020
+
1021
+ return createOk({
1022
+ driver: result.data.driver,
1023
+ targetId: result.data.targetId,
1024
+ ...result.data.data
1025
+ });
1026
+ });
1027
+
1028
+ toolMap.set("browser.cookie.set", async (args) => {
1029
+ const name = requireStringArg(args, "name");
1030
+ if (!name.ok) {
1031
+ return name;
1032
+ }
1033
+
1034
+ const value = requireStringArg(args, "value");
1035
+ if (!value.ok) {
1036
+ return value;
1037
+ }
1038
+
1039
+ const url = resolveOptionalStringArg(args, "url");
1040
+ if (!url.ok) {
1041
+ return url;
1042
+ }
1043
+
1044
+ const result = await runStructuredAction(args, "cookieSet", {
1045
+ name: name.data,
1046
+ value: value.data,
1047
+ ...(url.data !== undefined ? { url: url.data } : {})
1048
+ });
1049
+ if (!result.ok) {
1050
+ return result;
1051
+ }
1052
+
1053
+ return createOk({
1054
+ driver: result.data.driver,
1055
+ targetId: result.data.targetId,
1056
+ ...result.data.data
1057
+ });
1058
+ });
1059
+
1060
+ toolMap.set("browser.cookie.clear", async (args) => {
1061
+ const name = resolveOptionalStringArg(args, "name");
1062
+ if (!name.ok) {
1063
+ return name;
1064
+ }
1065
+
1066
+ const result = await runStructuredAction(args, "cookieClear", {
1067
+ ...(name.data !== undefined ? { name: name.data } : {})
1068
+ });
1069
+ if (!result.ok) {
1070
+ return result;
1071
+ }
1072
+
1073
+ return createOk({
1074
+ driver: result.data.driver,
1075
+ targetId: result.data.targetId,
1076
+ ...result.data.data
1077
+ });
1078
+ });
1079
+
1080
+ toolMap.set("browser.storage.get", async (args) => {
1081
+ const scope = requireStringArg(args, "scope");
1082
+ if (!scope.ok) {
1083
+ return scope;
1084
+ }
1085
+
1086
+ if (scope.data !== "local" && scope.data !== "session") {
1087
+ return createErr(ErrorCode.E_INVALID_ARG, "scope must be local or session.");
1088
+ }
1089
+
1090
+ const key = requireStringArg(args, "key");
1091
+ if (!key.ok) {
1092
+ return key;
1093
+ }
1094
+
1095
+ const result = await runStructuredAction(args, "storageGet", {
1096
+ scope: scope.data,
1097
+ key: key.data
1098
+ });
1099
+ if (!result.ok) {
1100
+ return result;
1101
+ }
1102
+
1103
+ return createOk({
1104
+ driver: result.data.driver,
1105
+ targetId: result.data.targetId,
1106
+ ...result.data.data
1107
+ });
1108
+ });
1109
+
1110
+ toolMap.set("browser.storage.set", async (args) => {
1111
+ const scope = requireStringArg(args, "scope");
1112
+ if (!scope.ok) {
1113
+ return scope;
1114
+ }
1115
+
1116
+ if (scope.data !== "local" && scope.data !== "session") {
1117
+ return createErr(ErrorCode.E_INVALID_ARG, "scope must be local or session.");
1118
+ }
1119
+
1120
+ const key = requireStringArg(args, "key");
1121
+ if (!key.ok) {
1122
+ return key;
1123
+ }
1124
+
1125
+ const value = requireStringArg(args, "value");
1126
+ if (!value.ok) {
1127
+ return value;
1128
+ }
1129
+
1130
+ const result = await runStructuredAction(args, "storageSet", {
1131
+ scope: scope.data,
1132
+ key: key.data,
1133
+ value: value.data
1134
+ });
1135
+ if (!result.ok) {
1136
+ return result;
1137
+ }
1138
+
1139
+ return createOk({
1140
+ driver: result.data.driver,
1141
+ targetId: result.data.targetId,
1142
+ ...result.data.data
1143
+ });
1144
+ });
1145
+
1146
+ toolMap.set("browser.frame.list", async (args) => {
1147
+ const result = await runStructuredAction(args, "frameList", {});
1148
+ if (!result.ok) {
1149
+ return result;
1150
+ }
1151
+
1152
+ return createOk({
1153
+ driver: result.data.driver,
1154
+ targetId: result.data.targetId,
1155
+ ...result.data.data
1156
+ });
1157
+ });
1158
+
1159
+ toolMap.set("browser.frame.snapshot", async (args) => {
1160
+ const frameId = requireStringArg(args, "frameId");
1161
+ if (!frameId.ok) {
1162
+ return frameId;
1163
+ }
1164
+
1165
+ const result = await runStructuredAction(args, "frameSnapshot", {
1166
+ frameId: frameId.data
1167
+ });
1168
+ if (!result.ok) {
1169
+ return result;
1170
+ }
1171
+
1172
+ return createOk({
1173
+ driver: result.data.driver,
1174
+ targetId: result.data.targetId,
1175
+ ...result.data.data
1176
+ });
1177
+ });
1178
+
1179
+ toolMap.set("browser.act", async (args) =>
1180
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1181
+ const targetId = resolveTargetId(args, sessions);
1182
+ if (!targetId.ok) {
1183
+ return targetId;
1184
+ }
1185
+
1186
+ const action = resolveAction(args);
1187
+ if (!action.ok) {
1188
+ return action;
1189
+ }
1190
+
1191
+ const result = await driver.act(action.data, targetId.data);
1192
+ return createOk({
1193
+ driver: driverKey,
1194
+ targetId: targetId.data,
1195
+ result
1196
+ });
1197
+ })
1198
+ );
1199
+
1200
+ toolMap.set("browser.upload.arm", async (args) =>
1201
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1202
+ const targetId = resolveTargetId(args, sessions);
1203
+ if (!targetId.ok) {
1204
+ return targetId;
1205
+ }
1206
+
1207
+ const files = resolveFiles(args);
1208
+ if (!files.ok) {
1209
+ return files;
1210
+ }
1211
+
1212
+ const rootedFiles = applyUploadRoot(files.data, config.uploadRoot);
1213
+ if (!rootedFiles.ok) {
1214
+ return rootedFiles;
1215
+ }
1216
+
1217
+ await driver.armUpload(targetId.data, rootedFiles.data);
1218
+ return createOk({
1219
+ driver: driverKey,
1220
+ targetId: targetId.data,
1221
+ armed: true,
1222
+ files: rootedFiles.data
1223
+ });
1224
+ })
1225
+ );
1226
+
1227
+ toolMap.set("browser.dialog.arm", async (args) =>
1228
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1229
+ const targetId = resolveTargetId(args, sessions);
1230
+ if (!targetId.ok) {
1231
+ return targetId;
1232
+ }
1233
+
1234
+ await driver.armDialog(targetId.data);
1235
+ return createOk({
1236
+ driver: driverKey,
1237
+ targetId: targetId.data,
1238
+ armed: true
1239
+ });
1240
+ })
1241
+ );
1242
+
1243
+ toolMap.set("browser.download.trigger", async (args) =>
1244
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1245
+ const targetId = resolveTargetId(args, sessions);
1246
+ if (!targetId.ok) {
1247
+ return targetId;
1248
+ }
1249
+
1250
+ await driver.triggerDownload(targetId.data);
1251
+ return createOk({
1252
+ driver: driverKey,
1253
+ targetId: targetId.data,
1254
+ triggered: true
1255
+ });
1256
+ })
1257
+ );
1258
+
1259
+ toolMap.set("browser.download.wait", async (args) =>
1260
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1261
+ const targetId = resolveTargetId(args, sessions);
1262
+ if (!targetId.ok) {
1263
+ return targetId;
1264
+ }
1265
+
1266
+ const requestedPath = resolveOptionalPathArg(args, "path");
1267
+ if (!requestedPath.ok) {
1268
+ return requestedPath;
1269
+ }
1270
+
1271
+ const download = await driver.waitDownload(targetId.data);
1272
+ const normalizedDownload = normalizeDownloadPayload(
1273
+ download,
1274
+ requestedPath.data,
1275
+ config.downloadRoot
1276
+ );
1277
+ if (!normalizedDownload.ok) {
1278
+ return normalizedDownload;
1279
+ }
1280
+
1281
+ return createOk({
1282
+ driver: driverKey,
1283
+ targetId: targetId.data,
1284
+ download: normalizedDownload.data
1285
+ });
1286
+ })
1287
+ );
1288
+
1289
+ toolMap.set("browser.console.list", async (args) =>
1290
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1291
+ const targetId = resolveTargetId(args, sessions);
1292
+ if (!targetId.ok) {
1293
+ return targetId;
1294
+ }
1295
+
1296
+ const snapshot = await driver.snapshot(targetId.data);
1297
+ if (!isTargetKnownSnapshot(snapshot)) {
1298
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
1299
+ }
1300
+
1301
+ if (hasConsoleEntriesGetter(driver)) {
1302
+ return createOk({
1303
+ driver: driverKey,
1304
+ targetId: targetId.data,
1305
+ entries: driver.getConsoleEntries(targetId.data)
1306
+ });
1307
+ }
1308
+
1309
+ return createOk({
1310
+ driver: driverKey,
1311
+ targetId: targetId.data,
1312
+ entries: []
1313
+ });
1314
+ })
1315
+ );
1316
+
1317
+ toolMap.set("browser.network.waitFor", async (args) =>
1318
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1319
+ const targetId = resolveTargetId(args, sessions);
1320
+ if (!targetId.ok) {
1321
+ return targetId;
1322
+ }
1323
+
1324
+ const urlPattern = requireStringArg(args, "urlPattern");
1325
+ if (!urlPattern.ok) {
1326
+ return urlPattern;
1327
+ }
1328
+
1329
+ const method = resolveOptionalStringArg(args, "method");
1330
+ if (!method.ok) {
1331
+ return method;
1332
+ }
1333
+
1334
+ const status = resolveOptionalIntegerArg(args, "status", 100);
1335
+ if (!status.ok) {
1336
+ return status;
1337
+ }
1338
+
1339
+ const timeoutMs = resolveOptionalIntegerArg(args, "timeoutMs", 1);
1340
+ if (!timeoutMs.ok) {
1341
+ return timeoutMs;
1342
+ }
1343
+
1344
+ const pollMs = resolveOptionalIntegerArg(args, "pollMs", 1);
1345
+ if (!pollMs.ok) {
1346
+ return pollMs;
1347
+ }
1348
+
1349
+ const methodPattern = method.data?.toUpperCase();
1350
+ const expectedStatus = status.data;
1351
+ const maxWaitMs = timeoutMs.data ?? DEFAULT_NETWORK_WAIT_TIMEOUT_MS;
1352
+ const intervalMs = pollMs.data ?? DEFAULT_NETWORK_WAIT_POLL_MS;
1353
+ const startTime = Date.now();
1354
+
1355
+ while (Date.now() - startTime <= maxWaitMs) {
1356
+ const snapshot = await driver.snapshot(targetId.data);
1357
+ if (!isTargetKnownSnapshot(snapshot)) {
1358
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
1359
+ }
1360
+
1361
+ const summaries = readNetworkRequestSummaries(snapshot);
1362
+ const matchedSummary = summaries
1363
+ .slice()
1364
+ .reverse()
1365
+ .find((summary) => {
1366
+ if (!summary.url.includes(urlPattern.data)) {
1367
+ return false;
1368
+ }
1369
+
1370
+ if (
1371
+ methodPattern !== undefined &&
1372
+ (summary.method === undefined || summary.method.toUpperCase() !== methodPattern)
1373
+ ) {
1374
+ return false;
1375
+ }
1376
+
1377
+ if (expectedStatus !== undefined && summary.status !== expectedStatus) {
1378
+ return false;
1379
+ }
1380
+
1381
+ return true;
1382
+ });
1383
+
1384
+ if (matchedSummary !== undefined) {
1385
+ return createOk({
1386
+ driver: driverKey,
1387
+ targetId: targetId.data,
1388
+ request: matchedSummary,
1389
+ elapsedMs: Date.now() - startTime
1390
+ });
1391
+ }
1392
+
1393
+ await delay(intervalMs);
1394
+ }
1395
+
1396
+ return createErr(
1397
+ ErrorCode.E_TIMEOUT,
1398
+ `Timed out waiting for network response: ${urlPattern.data}`
1399
+ );
1400
+ })
1401
+ );
1402
+
1403
+ toolMap.set("browser.network.responseBody", async (args) =>
1404
+ runWithDefaultDriver(args, async (driver, driverKey) => {
1405
+ const targetId = resolveTargetId(args, sessions);
1406
+ if (!targetId.ok) {
1407
+ return targetId;
1408
+ }
1409
+
1410
+ const requestId = resolveRequestId(args);
1411
+ if (!requestId.ok) {
1412
+ return requestId;
1413
+ }
1414
+
1415
+ const snapshot = await driver.snapshot(targetId.data);
1416
+ if (!isTargetKnownSnapshot(snapshot)) {
1417
+ return createErr(ErrorCode.E_NOT_FOUND, `Unknown targetId: ${targetId.data}`);
1418
+ }
1419
+
1420
+ if (hasNetworkResponseBodyGetter(driver)) {
1421
+ const responseBody = driver.getNetworkResponseBody(requestId.data, targetId.data);
1422
+ if (responseBody === undefined) {
1423
+ return createErr(
1424
+ ErrorCode.E_NOT_FOUND,
1425
+ `Unknown requestId: ${requestId.data} (target: ${targetId.data})`
1426
+ );
1427
+ }
1428
+
1429
+ return createOk({
1430
+ driver: driverKey,
1431
+ targetId: targetId.data,
1432
+ requestId: requestId.data,
1433
+ body: responseBody.body,
1434
+ encoding: responseBody.encoding
1435
+ });
1436
+ }
1437
+
1438
+ return createOk({
1439
+ driver: driverKey,
1440
+ targetId: targetId.data,
1441
+ requestId: requestId.data,
1442
+ body: "",
1443
+ encoding: "utf8"
1444
+ });
1445
+ })
1446
+ );
1447
+
1448
+ for (const [toolName, handler] of [...toolMap.entries()]) {
1449
+ toolMap.set(toolName, async (args) => {
1450
+ const authorization = authorizeToolCall(args, toolName, config.authToken, allowedScopes);
1451
+ if (!authorization.ok) {
1452
+ return authorization;
1453
+ }
1454
+
1455
+ return await handler(args);
1456
+ });
1457
+ }
1458
+
1459
+ return toolMap;
1460
+ }
1461
+
1462
+ export function createContainer(config: BrowserdConfig = loadBrowserdConfig()): BrowserdContainer {
1463
+ const sessions = new SessionStore();
1464
+ const driverRegistry = new DriverRegistry();
1465
+ const drivers = new Map<string, BrowserDriver>();
1466
+ const cleanupHandlers: Array<() => void> = [];
1467
+
1468
+ function registerDriver(driverKey: string, driver: BrowserDriver): void {
1469
+ driverRegistry.register(driverKey, driver);
1470
+ drivers.set(driverKey, driver);
1471
+ }
1472
+
1473
+ registerDriver(DEFAULT_DRIVER_KEY, createManagedDriver());
1474
+ if (config.managedLocalEnabled) {
1475
+ registerDriver(
1476
+ MANAGED_LOCAL_DRIVER_KEY,
1477
+ createManagedLocalDriver({
1478
+ browserName: config.managedLocalLaunch.browserName,
1479
+ headless: config.managedLocalLaunch.headless,
1480
+ channel: config.managedLocalLaunch.channel,
1481
+ executablePath: config.managedLocalLaunch.executablePath,
1482
+ launchTimeoutMs: config.managedLocalLaunch.launchTimeoutMs,
1483
+ args: [...config.managedLocalLaunch.args]
1484
+ })
1485
+ );
1486
+ }
1487
+ const chromeRelayDriver =
1488
+ config.chromeRelayMode === "extension"
1489
+ ? (() => {
1490
+ const extensionBridge = createChromeRelayExtensionBridge({
1491
+ relayUrl: config.chromeRelayUrl,
1492
+ token: config.chromeRelayExtensionToken,
1493
+ requestTimeoutMs: config.chromeRelayExtensionRequestTimeoutMs
1494
+ });
1495
+ cleanupHandlers.push(() => {
1496
+ void extensionBridge.close();
1497
+ });
1498
+
1499
+ return createChromeRelayDriver({
1500
+ relayUrl: config.chromeRelayUrl,
1501
+ runtime: createChromeRelayExtensionRuntime({
1502
+ transport: {
1503
+ invoke: async (method, params) => await extensionBridge.invoke(method, params),
1504
+ isConnected: () => extensionBridge.isConnected(),
1505
+ onEvent: (listener) => extensionBridge.onEvent(listener)
1506
+ }
1507
+ })
1508
+ });
1509
+ })()
1510
+ : createChromeRelayDriver({ relayUrl: config.chromeRelayUrl });
1511
+ registerDriver("chrome-relay", chromeRelayDriver);
1512
+ registerDriver("remote-cdp", createRemoteCdpDriver({ cdpUrl: config.remoteCdpUrl }));
1513
+ const defaultDriverKey = drivers.has(config.defaultDriver)
1514
+ ? config.defaultDriver
1515
+ : DEFAULT_DRIVER_KEY;
1516
+
1517
+ return {
1518
+ config,
1519
+ drivers,
1520
+ driverRegistry,
1521
+ sessions,
1522
+ mcpServer: createMcpStdioServer(
1523
+ createBrowserdToolMap(drivers, sessions, defaultDriverKey, config)
1524
+ ),
1525
+ close() {
1526
+ for (const cleanupHandler of [...cleanupHandlers].reverse()) {
1527
+ cleanupHandler();
1528
+ }
1529
+ }
1530
+ };
1531
+ }