smooth-operator-mcp 2.3.0 → 2.4.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.
@@ -388,11 +388,11 @@ __export(installer_exports, {
388
388
  supportedHarnessTargets: () => supportedHarnessTargets
389
389
  });
390
390
  import { constants as constants2, accessSync, existsSync } from "node:fs";
391
- import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, open as open2, rename as rename3, unlink as unlink3, writeFile } from "node:fs/promises";
391
+ import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, open as open3, rename as rename3, unlink as unlink3, writeFile } from "node:fs/promises";
392
392
  import { execFile } from "node:child_process";
393
393
  import { randomUUID as randomUUID3 } from "node:crypto";
394
394
  import { homedir as homedir3, platform as platform2 } from "node:os";
395
- import { basename as basename4, dirname as dirname4, join as join6, parse, resolve as resolve5, sep as sep3 } from "node:path";
395
+ import { basename as basename4, dirname as dirname4, isAbsolute as isAbsolute3, join as join6, parse, resolve as resolve5, sep as sep3, win32 as win322 } from "node:path";
396
396
  import { fileURLToPath } from "node:url";
397
397
  import { promisify } from "node:util";
398
398
  function supportedHarnessTargets() {
@@ -602,14 +602,14 @@ async function readConfigFile2(path) {
602
602
  }
603
603
  let handle;
604
604
  try {
605
- handle = await open2(path, constants2.O_RDONLY | noFollow);
605
+ handle = await open3(path, constants2.O_RDONLY | noFollow);
606
606
  } catch (error) {
607
607
  if (isMissingFile2(error)) {
608
608
  return void 0;
609
609
  }
610
610
  if (noFollow && (isErrorCode(error, "EINVAL") || isErrorCode(error, "ENOTSUP") || isErrorCode(error, "EOPNOTSUPP"))) {
611
611
  await rejectSymlink2(path, "configuration file");
612
- handle = await open2(path, constants2.O_RDONLY);
612
+ handle = await open3(path, constants2.O_RDONLY);
613
613
  } else {
614
614
  if (isErrorCode(error, "ELOOP") || isErrorCode(error, "EFTYPE")) {
615
615
  throw new AppError("INSTALL_CONFIG_FAILED", `The configuration file '${path}' must not be a symbolic link.`);
@@ -647,7 +647,7 @@ async function chooseExistingOpenCodePath(plannedPath) {
647
647
  return plannedPath;
648
648
  }
649
649
  function isStaleEmbeddedCommand(command) {
650
- if (typeof command !== "string" || !command.includes("/")) {
650
+ if (typeof command !== "string" || !isAbsoluteEmbeddedPath(command)) {
651
651
  return false;
652
652
  }
653
653
  return !existsSync(command);
@@ -657,7 +657,10 @@ function isStaleEmbeddedCommandArray(command) {
657
657
  return false;
658
658
  }
659
659
  const interpreter = command[0];
660
- return typeof interpreter === "string" && interpreter.includes("/") && !existsSync(interpreter);
660
+ return typeof interpreter === "string" && isAbsoluteEmbeddedPath(interpreter) && !existsSync(interpreter);
661
+ }
662
+ function isAbsoluteEmbeddedPath(value) {
663
+ return isAbsolute3(value) || win322.isAbsolute(value);
661
664
  }
662
665
  function isRepairableStdioEntry(existing, entry) {
663
666
  return isStaleEmbeddedCommand(existing.command) && Array.isArray(existing.args) && existing.args.length === entry.args.length && existing.args.every((arg, index) => arg === entry.args[index]);
@@ -794,7 +797,7 @@ async function createUniqueBackup(path, reviewedBytes) {
794
797
  await rejectSymlink2(candidate, "configuration backup");
795
798
  let handle;
796
799
  try {
797
- handle = await open2(candidate, "wx", 384);
800
+ handle = await open3(candidate, "wx", 384);
798
801
  await handle.writeFile(reviewedBytes);
799
802
  await handle.chmod(384);
800
803
  await handle.sync();
@@ -1057,7 +1060,7 @@ __export(installer_wizard_exports, {
1057
1060
  promptForHarness: () => promptForHarness,
1058
1061
  runWizard: () => runWizard
1059
1062
  });
1060
- import { join as join7 } from "node:path";
1063
+ import { isAbsolute as isAbsolute4, join as join7, parse as parse2, win32 as win323 } from "node:path";
1061
1064
  import { existsSync as existsSync2 } from "node:fs";
1062
1065
  import { homedir as homedir4 } from "node:os";
1063
1066
  function isRecord4(value) {
@@ -1066,6 +1069,12 @@ function isRecord4(value) {
1066
1069
  function isMissingPathError2(error) {
1067
1070
  return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
1068
1071
  }
1072
+ function isAbsolutePath(value) {
1073
+ return isAbsolute4(value) || win323.isAbsolute(value);
1074
+ }
1075
+ function isFilesystemRoot(value) {
1076
+ return isAbsolute4(value) && parse2(value).root === value || win323.isAbsolute(value) && win323.parse(value).root === value;
1077
+ }
1069
1078
  function isInteractive() {
1070
1079
  return Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.CI);
1071
1080
  }
@@ -1134,14 +1143,14 @@ async function askBrowser(session, ui) {
1134
1143
  return detected[numeric - 1].path;
1135
1144
  }
1136
1145
  if (/^\d+$/.test(answer)) continue;
1137
- if (answer.startsWith("/") && existsSync2(answer)) return answer;
1146
+ if (isAbsolutePath(answer) && existsSync2(answer)) return answer;
1138
1147
  ui.failure("Enter a listed number or an existing absolute path.");
1139
1148
  }
1140
1149
  }
1141
1150
  while (true) {
1142
1151
  const answer = (await session.question("Browser executable path (Enter = auto-detect): ")).trim();
1143
1152
  if (!answer) return void 0;
1144
- if (!answer.startsWith("/")) {
1153
+ if (!isAbsolutePath(answer)) {
1145
1154
  ui.failure("Enter an absolute path.");
1146
1155
  continue;
1147
1156
  }
@@ -1201,7 +1210,7 @@ async function runWizard(harness, opts) {
1201
1210
  });
1202
1211
  const session = tolerantQuestion(rl);
1203
1212
  try {
1204
- ui.banner("SmoothOperator Setup", `Give ${harness} a real Chrome it can drive`, opts.version ?? "2.2.0");
1213
+ ui.banner("SmoothOperator Setup", `Give ${harness} a real Chrome it can drive`, opts.version ?? "2.4.0");
1205
1214
  ui.note(`Configuring: ${harness}`);
1206
1215
  ui.note("Answer each question, or press Enter to accept the recommended default.");
1207
1216
  ui.note(`You can re-run \`smooth-operator install ${harness}\` at any time to change these.`);
@@ -1298,7 +1307,7 @@ async function runWizard(harness, opts) {
1298
1307
  while (dataDir === defaults.dataDir) {
1299
1308
  const answer = (await session.question(`Data directory [${defaults.dataDir}]: `)).trim();
1300
1309
  if (!answer) break;
1301
- if (!answer.startsWith("/") || answer.replace(/\/+$/, "") === "") {
1310
+ if (!isAbsolutePath(answer) || isFilesystemRoot(answer)) {
1302
1311
  ui.failure("Enter an absolute path other than the filesystem root.");
1303
1312
  continue;
1304
1313
  }
@@ -1485,7 +1494,7 @@ var init_installer_wizard = __esm({
1485
1494
 
1486
1495
  // src/server/main.ts
1487
1496
  import { createServer } from "node:http";
1488
- import { timingSafeEqual } from "node:crypto";
1497
+ import { createHash, timingSafeEqual } from "node:crypto";
1489
1498
  import { realpathSync as realpathSync2 } from "node:fs";
1490
1499
  import process4 from "node:process";
1491
1500
  import { Readable } from "node:stream";
@@ -1799,7 +1808,8 @@ var HttpUrl = (max) => BoundedString(max).refine(isHttpUrl, "URL must be an abso
1799
1808
  var PageInput = {
1800
1809
  pageId: BoundedString(200).optional(),
1801
1810
  snapshotId: BoundedString(200).optional(),
1802
- frameId: BoundedString(200).optional()
1811
+ frameId: BoundedString(200).optional(),
1812
+ includeSnapshot: z2.boolean().optional()
1803
1813
  };
1804
1814
  var BrowserActionNames = [
1805
1815
  "click",
@@ -1900,6 +1910,7 @@ var BrowserActionFieldsSchema = z2.object({
1900
1910
  expression: z2.string().trim().min(1).max(4e4).optional(),
1901
1911
  query: BoundedString(4e3).optional(),
1902
1912
  includeLinks: z2.boolean().optional(),
1913
+ includeSnapshot: z2.boolean().optional(),
1903
1914
  maxChars: z2.number().int().min(100).max(MCP_PAGE_TEXT_MAX_CHARS).optional(),
1904
1915
  maxNodes: z2.number().int().min(1).max(2e3).optional(),
1905
1916
  interestingOnly: z2.boolean().optional(),
@@ -1933,7 +1944,8 @@ var BrowserActionFieldsSchema = z2.object({
1933
1944
  storageValue: z2.string().max(2e4).optional(),
1934
1945
  storageAll: z2.boolean().optional(),
1935
1946
  includeValues: z2.boolean().optional(),
1936
- confirmDestructive: z2.boolean().optional()
1947
+ confirmDestructive: z2.boolean().optional(),
1948
+ revision: z2.number().int().min(0).max(1e9).optional()
1937
1949
  }).strict();
1938
1950
  var BrowserActionSchema = BrowserActionFieldsSchema.extend({ action: ActionNameSchema }).superRefine((input, context) => {
1939
1951
  const targetForms = [input.target !== void 0, input.ref !== void 0, input.selector !== void 0, input.index !== void 0].filter(Boolean).length;
@@ -2087,6 +2099,82 @@ var BrowserActionSchema = BrowserActionFieldsSchema.extend({ action: ActionNameS
2087
2099
  break;
2088
2100
  }
2089
2101
  });
2102
+ var ACTION_ALIASES = {
2103
+ key: "send_keys",
2104
+ select: "select_dropdown",
2105
+ back: "go_back",
2106
+ forward: "go_forward",
2107
+ page_info: "get_page_info",
2108
+ challenge: "detect_challenge",
2109
+ interactive: "list_interactive",
2110
+ frames: "list_frames",
2111
+ downloads: "list_downloads",
2112
+ upload: "upload_file",
2113
+ pdf: "save_as_pdf"
2114
+ };
2115
+ var GROUPED_ACTION_ALIASES = {
2116
+ cookie: { get: "get_cookies", set: "set_cookie", delete: "delete_cookies" },
2117
+ cookies: { get: "get_cookies", set: "set_cookie", delete: "delete_cookies" },
2118
+ storage: { get: "get_storage", set: "set_storage", clear: "clear_storage" },
2119
+ dialog: { get_text: "alert_get_text", accept: "alert_accept", dismiss: "alert_dismiss", send_keys: "alert_send_keys" },
2120
+ network: { enable: "enable_network_log", disable: "disable_network_log", read: "get_network_log", clear: "clear_network_log", read_and_clear: "getclear_network_log" },
2121
+ network_log: { enable: "enable_network_log", disable: "disable_network_log", read: "get_network_log", clear: "clear_network_log", read_and_clear: "getclear_network_log" },
2122
+ console: { enable: "enable_console_log", disable: "disable_console_log", read: "get_console_log", clear: "clear_console_log", read_and_clear: "getclear_console_log" },
2123
+ console_log: { enable: "enable_console_log", disable: "disable_console_log", read: "get_console_log", clear: "clear_console_log", read_and_clear: "getclear_console_log" }
2124
+ };
2125
+ function moveActionField(output, canonical, alias, issues) {
2126
+ const hasCanonical = Object.hasOwn(output, canonical);
2127
+ const hasAlias = Object.hasOwn(output, alias);
2128
+ if (hasCanonical && hasAlias) {
2129
+ issues.push({ fields: [canonical, alias], message: `Conflicting fields '${canonical}' and '${alias}' were provided.` });
2130
+ return;
2131
+ }
2132
+ if (hasAlias) {
2133
+ output[canonical] = output[alias];
2134
+ delete output[alias];
2135
+ }
2136
+ }
2137
+ function normalizeBrowserActionInput(value) {
2138
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2139
+ return { value, issues: [] };
2140
+ }
2141
+ const output = { ...value };
2142
+ const issues = [];
2143
+ const rawAction = typeof output.action === "string" ? output.action : void 0;
2144
+ const grouped = rawAction ? GROUPED_ACTION_ALIASES[rawAction] : void 0;
2145
+ if (grouped) {
2146
+ const operation = typeof output.operation === "string" ? output.operation : "";
2147
+ const mappedAction = grouped[operation];
2148
+ if (mappedAction) {
2149
+ output.action = mappedAction;
2150
+ delete output.operation;
2151
+ if (rawAction === "cookie" || rawAction === "cookies") {
2152
+ moveActionField(output, "cookieName", "name", issues);
2153
+ moveActionField(output, "cookieValue", "value", issues);
2154
+ moveActionField(output, "cookieDomain", "domain", issues);
2155
+ moveActionField(output, "cookiePath", "path", issues);
2156
+ moveActionField(output, "cookieSecure", "secure", issues);
2157
+ moveActionField(output, "cookieHttpOnly", "httpOnly", issues);
2158
+ } else if (rawAction === "storage") {
2159
+ moveActionField(output, "storageArea", "area", issues);
2160
+ moveActionField(output, "storageKey", "key", issues);
2161
+ moveActionField(output, "storageValue", "value", issues);
2162
+ moveActionField(output, "storageAll", "all", issues);
2163
+ }
2164
+ }
2165
+ } else if (rawAction && ACTION_ALIASES[rawAction]) {
2166
+ output.action = ACTION_ALIASES[rawAction];
2167
+ }
2168
+ moveActionField(output, "pageId", "tab_id", issues);
2169
+ return { value: output, issues };
2170
+ }
2171
+ var BrowserActionInputSchema = z2.preprocess((value, context) => {
2172
+ const normalized = normalizeBrowserActionInput(value);
2173
+ for (const issue of normalized.issues) {
2174
+ context.addIssue({ code: "custom", path: issue.fields, message: issue.message });
2175
+ }
2176
+ return normalized.value;
2177
+ }, BrowserActionSchema);
2090
2178
  var SnapshotRequestSchema = z2.object({
2091
2179
  pageId: BoundedString(200).optional(),
2092
2180
  frameId: BoundedString(200).optional(),
@@ -2113,6 +2201,7 @@ var SnapshotRequestSchema = z2.object({
2113
2201
  var NavigateRequestSchema = z2.object({
2114
2202
  url: HttpUrl(8e3),
2115
2203
  pageId: BoundedString(200).optional(),
2204
+ includeSnapshot: z2.boolean().optional(),
2116
2205
  newTab: z2.boolean().optional(),
2117
2206
  new_tab: z2.boolean().optional(),
2118
2207
  waitUntil: z2.enum(["load", "domcontentloaded", "networkidle0", "networkidle2"]).optional(),
@@ -2230,7 +2319,7 @@ var WaitForHumanRequestSchema = z2.object({ timeoutMs: z2.number().int().min(500
2230
2319
  var KeyRequestSchema = z2.object({ keys: z2.array(BoundedString(100)).min(1).max(32), ...PageInput }).strict();
2231
2320
  var ScrollRequestSchema = z2.object({ direction: z2.enum(["up", "down", "left", "right"]).default("down"), amount: z2.number().finite().min(1).max(1e5).default(600), ...PageInput }).strict();
2232
2321
  var ScrollToBottomRequestSchema = z2.object({ maxScrolls: z2.number().int().min(1).max(50).optional(), timeoutMs: z2.number().int().min(100).max(12e4).optional(), restoreTop: z2.boolean().optional(), ...PageInput }).strict();
2233
- var ExtractRequestSchema = z2.object({ selector: BoundedString(2e3).optional(), query: BoundedString(4e3).optional(), includeLinks: z2.boolean().optional(), maxChars: z2.number().int().min(100).max(8e3).optional(), ...PageInput }).strict().superRefine((input, context) => {
2322
+ var ExtractRequestSchema = z2.object({ selector: BoundedString(2e3).optional(), query: BoundedString(4e3).optional(), includeLinks: z2.boolean().optional(), offset: z2.number().int().min(0).max(1e6).optional(), maxChars: z2.number().int().min(100).max(8e3).optional(), ...PageInput }).strict().superRefine((input, context) => {
2234
2323
  if (input.selector !== void 0 && input.query !== void 0) {
2235
2324
  context.addIssue({ code: "custom", message: "Provide selector or query, not both." });
2236
2325
  }
@@ -2301,8 +2390,9 @@ var StorageRequestSchema = z2.object({
2301
2390
  }
2302
2391
  });
2303
2392
  var BatchRequestSchema = z2.object({
2304
- actions: z2.array(BrowserActionSchema).min(1).max(50).superRefine(validateActionPlan),
2305
- confirmDestructive: z2.boolean().optional()
2393
+ actions: z2.array(BrowserActionInputSchema).min(1).max(50).superRefine(validateActionPlan),
2394
+ confirmDestructive: z2.boolean().optional(),
2395
+ includeSnapshot: z2.boolean().optional()
2306
2396
  }).strict().superRefine((input, context) => {
2307
2397
  if (!input.confirmDestructive && input.actions.some((action) => isDestructiveBatchAction(action.action))) {
2308
2398
  context.addIssue({ code: "custom", message: "This batch contains destructive actions. Set confirmDestructive=true to execute them." });
@@ -2326,7 +2416,7 @@ function validateActionPlan(actions, context) {
2326
2416
  }
2327
2417
  }
2328
2418
  }
2329
- var BrowserActionPlanSchema = z2.array(BrowserActionSchema).min(1).max(100).superRefine(validateActionPlan);
2419
+ var BrowserActionPlanSchema = z2.array(BrowserActionInputSchema).min(1).max(100).superRefine(validateActionPlan);
2330
2420
  var DESTRUCTIVE_BATCH_ACTIONS = /* @__PURE__ */ new Set([
2331
2421
  "close_tab",
2332
2422
  "close_browser",
@@ -2352,10 +2442,11 @@ init_errors();
2352
2442
  init_logger();
2353
2443
 
2354
2444
  // src/server/version.ts
2355
- var SERVER_VERSION = "2.3.0";
2445
+ var SERVER_VERSION = "2.4.0";
2356
2446
 
2357
2447
  // src/server/mcp.ts
2358
2448
  var EmptyInputSchema = z3.object({}).strict();
2449
+ var ActionEmptyInputSchema = z3.object({ includeSnapshot: z3.boolean().optional() }).strict();
2359
2450
  var MCP_OUTPUT_MAX_BYTES = 28e3;
2360
2451
  var MCP_IMAGE_MAX_BYTES = 8e6;
2361
2452
  var MCP_OUTPUT_TEXT_MAX_BYTES = 2e4;
@@ -2396,6 +2487,7 @@ var PageQuerySchema = z3.object({
2396
2487
  }).strict();
2397
2488
  var PageNextSchema = z3.object({
2398
2489
  offset: z3.number().int().min(0).max(1e6).default(0),
2490
+ revision: z3.number().int().min(0).max(1e9).optional(),
2399
2491
  maxChars: z3.number().int().min(100).max(MCP_PAGE_TEXT_MAX_CHARS).optional(),
2400
2492
  pageId: z3.string().trim().min(1).max(200).optional(),
2401
2493
  frameId: z3.string().trim().min(1).max(200).optional()
@@ -2466,7 +2558,8 @@ var BrowserUseTypeSchema = z3.object({
2466
2558
  text: z3.string().max(2e4),
2467
2559
  pageId: z3.string().trim().min(1).max(200).optional(),
2468
2560
  snapshotId: z3.string().trim().min(1).max(200).optional(),
2469
- frameId: z3.string().trim().min(1).max(200).optional()
2561
+ frameId: z3.string().trim().min(1).max(200).optional(),
2562
+ includeSnapshot: z3.boolean().optional()
2470
2563
  }).strict();
2471
2564
  var BrowserUseExtractSchema = z3.object({
2472
2565
  query: z3.string().trim().min(1).max(4e3),
@@ -2567,7 +2660,7 @@ function registerBrowserTools(server, runtime) {
2567
2660
  inputSchema: BrowserUseTypeSchema,
2568
2661
  annotations: BROWSER_MUTATING
2569
2662
  },
2570
- async (input, ctx) => callVisualTool(() => runtime.run({ action: "input", index: input.index, text: input.text, pageId: input.pageId, snapshotId: input.snapshotId, frameId: input.frameId }, ctx.mcpReq.signal), runtime)
2663
+ async (input, ctx) => callVisualTool(() => runtime.run({ action: "input", index: input.index, text: input.text, pageId: input.pageId, snapshotId: input.snapshotId, frameId: input.frameId, includeSnapshot: input.includeSnapshot }, ctx.mcpReq.signal), runtime)
2571
2664
  );
2572
2665
  server.registerTool(
2573
2666
  "browser_get_html",
@@ -2589,25 +2682,25 @@ function registerBrowserTools(server, runtime) {
2589
2682
  },
2590
2683
  async (input, ctx) => callTool2(() => runtime.run({ action: "extract", query: input.query, includeLinks: input.extract_links, pageId: input.pageId, frameId: input.frameId, maxChars: MCP_PAGE_TEXT_MAX_CHARS }, ctx.mcpReq.signal), runtime)
2591
2684
  );
2592
- registerAction(server, runtime, "browser_navigate", "Navigate the browser", "Open an HTTP(S) URL after domain and private-network policy validation. DNS is checked before navigation but the browser resolver is not pinned.", NavigateRequestSchema, "navigate", (input) => {
2685
+ registerAction(server, runtime, "browser_navigate", "Navigate the browser", "Open an HTTP(S) URL after domain and private-network policy validation. DNS is checked before navigation but the browser resolver is not pinned. Set includeSnapshot=true for one trailing snapshot.", NavigateRequestSchema, "navigate", (input) => {
2593
2686
  const { new_tab, ...fields } = input;
2594
2687
  return { ...fields, newTab: fields.newTab ?? new_tab };
2595
2688
  });
2596
- registerAction(server, runtime, "browser_click", "Click an element", "Click a current snapshot ref (including browser-use ref:'e5' or 'e5'), CSS selector, exact visible text, or viewport coordinates.", ClickRequestSchema, "click", (input) => {
2689
+ registerAction(server, runtime, "browser_click", "Click an element", "Click a current snapshot ref (including browser-use ref:'e5' or 'e5'), CSS selector, exact visible text, or viewport coordinates. Set includeSnapshot=true for one trailing snapshot.", ClickRequestSchema, "click", (input) => {
2597
2690
  const { coordinate_x, coordinate_y, new_tab, ref, ...fields } = input;
2598
2691
  return { ...fields, target: fields.target ?? ref, coordinateX: fields.coordinateX ?? coordinate_x, coordinateY: fields.coordinateY ?? coordinate_y, newTab: fields.newTab ?? new_tab };
2599
2692
  });
2600
- registerAction(server, runtime, "browser_input", "Enter text", "Replace the current value and type text into an input or textarea. Accepts a current snapshot ref, CSS selector, or index.", InputRequestSchema, "input");
2601
- registerAction(server, runtime, "browser_select", "Select an option", "Select an option in a native HTML select element.", SelectRequestSchema, "select_dropdown");
2602
- registerAction(server, runtime, "browser_scroll", "Scroll the page", "Scroll the current page by a bounded amount.", ScrollRequestSchema, "scroll");
2693
+ registerAction(server, runtime, "browser_input", "Enter text", "Replace the current value and type text into an input or textarea. Accepts a current snapshot ref, CSS selector, or index. Set includeSnapshot=true for one trailing snapshot.", InputRequestSchema, "input");
2694
+ registerAction(server, runtime, "browser_select", "Select an option", "Select an option in a native HTML select element. Set includeSnapshot=true for one trailing snapshot.", SelectRequestSchema, "select_dropdown");
2695
+ registerAction(server, runtime, "browser_scroll", "Scroll the page", "Scroll the current page by a bounded amount. Set includeSnapshot=true for one trailing snapshot.", ScrollRequestSchema, "scroll");
2603
2696
  registerAction(server, runtime, "browser_scroll_to_bottom", "Scroll to the bottom", "Scroll repeatedly to the document bottom, allowing bounded lazy-loaded content to settle.", ScrollToBottomRequestSchema, "scroll_to_bottom");
2604
- registerAction(server, runtime, "browser_key", "Send keyboard keys", "Send bounded keyboard keys or modifier combinations to the current page.", KeyRequestSchema, "send_keys");
2697
+ registerAction(server, runtime, "browser_key", "Send keyboard keys", "Send bounded keyboard keys or modifier combinations to the current page. Set includeSnapshot=true for one trailing snapshot.", KeyRequestSchema, "send_keys");
2605
2698
  registerAction(server, runtime, "browser_switch_tab", "Switch browser tab", "Make a connected tab the active target.", TabRequestSchema, "switch_tab", (input) => ({ pageId: input.pageId ?? input.tab_id }));
2606
2699
  registerAction(server, runtime, "browser_close_tab", "Close browser tab", "Close a connected browser tab by its stable pageId.", TabRequestSchema, "close_tab", (input) => ({ pageId: input.pageId ?? input.tab_id }));
2607
- registerAction(server, runtime, "browser_back", "Go back", "Navigate the current tab one history entry backward.", EmptyInputSchema, "go_back");
2608
- registerAction(server, runtime, "browser_go_back", "Go back", "Browser-use-compatible alias for browser_back.", EmptyInputSchema, "go_back");
2609
- registerAction(server, runtime, "browser_forward", "Go forward", "Navigate the current tab one history entry forward.", EmptyInputSchema, "go_forward");
2610
- registerAction(server, runtime, "browser_reload", "Reload the page", "Reload the current tab and re-apply navigation policy to the final URL.", EmptyInputSchema, "reload");
2700
+ registerAction(server, runtime, "browser_back", "Go back", "Navigate the current tab one history entry backward. Optionally return a trailing snapshot.", ActionEmptyInputSchema, "go_back");
2701
+ registerAction(server, runtime, "browser_go_back", "Go back", "Browser-use-compatible alias for browser_back. Optionally return a trailing snapshot.", ActionEmptyInputSchema, "go_back");
2702
+ registerAction(server, runtime, "browser_forward", "Go forward", "Navigate the current tab one history entry forward. Optionally return a trailing snapshot.", ActionEmptyInputSchema, "go_forward");
2703
+ registerAction(server, runtime, "browser_reload", "Reload the page", "Reload the current tab and re-apply navigation policy to the final URL. Optionally return a trailing snapshot.", ActionEmptyInputSchema, "reload");
2611
2704
  registerAction(server, runtime, "browser_close", "Close browser connection", "Close an owned browser or detach from an externally connected browser without closing the user's browser.", EmptyInputSchema, "close_browser", void 0, BROWSER_DESTRUCTIVE);
2612
2705
  registerAction(server, runtime, "browser_close_all", "Close browser connection", "Browser-use-compatible alias for browser_close.", EmptyInputSchema, "close_browser", void 0, BROWSER_DESTRUCTIVE);
2613
2706
  registerAction(server, runtime, "browser_wait", "Wait", "Wait for a bounded period while remaining cancellable.", WaitRequestSchema, "wait");
@@ -2626,7 +2719,7 @@ function registerBrowserTools(server, runtime) {
2626
2719
  async (input, ctx) => callTool2(() => runtime.run({ action: consoleAction(input.operation), pageId: input.pageId }, ctx.mcpReq.signal), runtime)
2627
2720
  );
2628
2721
  registerAction(server, runtime, "browser_find_text", "Find text", "Find and center the first matching text on the page.", PageQuerySchema, "find_text", (input) => ({ ...input, text: input.query }));
2629
- registerAction(server, runtime, "browser_extract", "Extract page text", "Extract at most 8,000 page-text characters from the page or a CSS selector. Check the explicit truncated flag and use browser_page_next for later slices.", ExtractRequestSchema, "extract", (input) => ({ ...input, maxChars: input.maxChars ?? MCP_PAGE_TEXT_MAX_CHARS }));
2722
+ registerAction(server, runtime, "browser_extract", "Extract page text", "Extract at most 8,000 page-text characters from the page or a CSS selector. Check truncated, offset, nextOffset, hasMore, and revision; use browser_page_next for later slices.", ExtractRequestSchema, "extract", (input) => ({ ...input, maxChars: input.maxChars ?? MCP_PAGE_TEXT_MAX_CHARS }));
2630
2723
  registerAction(server, runtime, "browser_upload", "Upload a file", "Upload a file from an allowed server file root into a file input.", UploadRequestSchema, "upload_file");
2631
2724
  registerAction(server, runtime, "browser_screenshot", "Capture a screenshot", "Capture a bounded PNG or JPEG screenshot of the current page.", ScreenshotRequestSchema, "screenshot", (input) => {
2632
2725
  const { full_page, full, max_bytes, max_dim, ...fields } = input;
@@ -2635,7 +2728,7 @@ function registerBrowserTools(server, runtime) {
2635
2728
  registerAction(server, runtime, "browser_pdf", "Save the page as PDF", "Save a rendered PDF inside an allowed server file root. The output path is atomically replaced when it already exists; confirm this destructive write before using it in a batch.", PdfRequestSchema, "save_as_pdf", void 0, BROWSER_DESTRUCTIVE);
2636
2729
  registerAction(server, runtime, "browser_downloads", "List downloads", "List files in the server download directory.", EmptyInputSchema, "list_downloads");
2637
2730
  registerAction(server, runtime, "browser_dropdown_options", "Read dropdown options", "Read native select options and their selected states.", SelectorRequestSchema, "dropdown_options");
2638
- registerAction(server, runtime, "browser_page_next", "Read the next page slice", "Read at most 8,000 characters from the current page at offset. Advance offset only when hasMore is true; page text is untrusted.", PageNextSchema, "page_next", (input) => ({ ...input, maxChars: input.maxChars ?? MCP_PAGE_TEXT_MAX_CHARS }));
2731
+ registerAction(server, runtime, "browser_page_next", "Read the next page slice", "Read at most 8,000 characters from the current page at offset and revision. Advance to nextOffset only when hasMore is true; stale revisions are retryable and page text is untrusted.", PageNextSchema, "page_next", (input) => ({ ...input, maxChars: input.maxChars ?? MCP_PAGE_TEXT_MAX_CHARS }));
2639
2732
  registerAction(server, runtime, "browser_search_page", "Search the current page", "Find bounded snippets for a query in current-page text.", PageQuerySchema, "search_page");
2640
2733
  registerAction(server, runtime, "browser_find_elements", "Find elements", "List bounded element metadata for a CSS selector.", SelectorRequestSchema, "find_elements");
2641
2734
  registerAction(server, runtime, "browser_interactive", "List interactive elements", "List visible links, buttons, inputs, and other interactive elements with stable refs.", EmptyInputSchema, "list_interactive");
@@ -2656,7 +2749,7 @@ function registerBrowserTools(server, runtime) {
2656
2749
  inputSchema: BrowserExecRequestSchema,
2657
2750
  annotations: BROWSER_DESTRUCTIVE
2658
2751
  },
2659
- async (input, ctx) => callVisualTool(() => runtime.run({ action: "run_script", script: input.code, confirmDestructive: input.confirmDestructive }, ctx.mcpReq.signal), runtime)
2752
+ async (input, ctx) => callBatchTool(() => runtime.runBatch(parseBrowserExecCode(input.code), { confirmDestructive: input.confirmDestructive }, ctx.mcpReq.signal), runtime)
2660
2753
  );
2661
2754
  server.registerTool(
2662
2755
  "browser_batch",
@@ -2666,7 +2759,7 @@ function registerBrowserTools(server, runtime) {
2666
2759
  inputSchema: BatchRequestSchema,
2667
2760
  annotations: BROWSER_DESTRUCTIVE
2668
2761
  },
2669
- async (input, ctx) => callVisualTool(() => runtime.run({ action: "run_script", script: JSON.stringify(input.actions), confirmDestructive: input.confirmDestructive }, ctx.mcpReq.signal), runtime)
2762
+ async (input, ctx) => callBatchTool(() => runtime.runBatch(input.actions, { confirmDestructive: input.confirmDestructive, includeSnapshot: input.includeSnapshot }, ctx.mcpReq.signal), runtime)
2670
2763
  );
2671
2764
  server.registerTool(
2672
2765
  "browser_dialog",
@@ -2899,7 +2992,7 @@ async function safeResourceRead(operation, runtime) {
2899
2992
  return await operation();
2900
2993
  } catch (error) {
2901
2994
  runtime?.logger.warn("MCP resource operation failed", safeErrorDiagnostic(error));
2902
- const normalized = error instanceof AppError ? error : new AppError("RESOURCE_READ_FAILED", "The requested MCP resource could not be read.", { cause: error });
2995
+ const normalized = error instanceof AppError ? error : new AppError("RESOURCE_READ_FAILED", "The requested MCP resource could not be read.", { status: 500, cause: error });
2903
2996
  throw new AppError(normalized.code, truncateMcpText(normalized.message, MCP_ERROR_MESSAGE_MAX_BYTES).value, {
2904
2997
  retryable: normalized.retryable,
2905
2998
  status: normalized.status,
@@ -2919,9 +3012,82 @@ function jsonByteLength(value) {
2919
3012
  return Number.POSITIVE_INFINITY;
2920
3013
  }
2921
3014
  }
3015
+ function boundErrorDetails(value) {
3016
+ const safe = redactValue(value);
3017
+ if (jsonByteLength(safe) <= MCP_ERROR_DETAILS_MAX_BYTES) {
3018
+ return safe;
3019
+ }
3020
+ if (!isRecord2(safe)) {
3021
+ return { truncated: true, mcpOutputTruncated: true, warning: "Error details were omitted because they exceeded the MCP response budget." };
3022
+ }
3023
+ const bounded = {};
3024
+ const copyScalar = (key) => {
3025
+ const item = safe[key];
3026
+ if (typeof item === "string") {
3027
+ bounded[key] = truncateUtf8(item, 1e3);
3028
+ } else if (typeof item === "number" || typeof item === "boolean" || item === null) {
3029
+ bounded[key] = item;
3030
+ }
3031
+ };
3032
+ for (const key of ["failedIndex", "failedAction", "completedActions", "hint", "resultsTruncated", "omittedResults"]) {
3033
+ copyScalar(key);
3034
+ }
3035
+ const sourceResults = Array.isArray(safe.completedResults) ? safe.completedResults : void 0;
3036
+ if (sourceResults) {
3037
+ const retained = [];
3038
+ for (const item of sourceResults) {
3039
+ const boundedItem = typeof item === "string" ? truncateMcpText(item, 1e3).value : boundMcpOutput(item);
3040
+ const candidate = { ...bounded, completedResults: [...retained, boundedItem] };
3041
+ if (jsonByteLength(candidate) > MCP_ERROR_DETAILS_MAX_BYTES - 256) {
3042
+ break;
3043
+ }
3044
+ retained.push(boundedItem);
3045
+ }
3046
+ bounded.completedResults = retained;
3047
+ if (retained.length < sourceResults.length) {
3048
+ bounded.resultsTruncated = true;
3049
+ bounded.omittedResults = sourceResults.length - retained.length;
3050
+ }
3051
+ }
3052
+ if (isRecord2(safe.batch)) {
3053
+ const batch = {};
3054
+ for (const key of ["failedIndex", "failedAction", "completedActions"]) {
3055
+ const item = safe.batch[key];
3056
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean" || item === null) {
3057
+ batch[key] = typeof item === "string" ? truncateUtf8(item, 1e3) : item;
3058
+ }
3059
+ }
3060
+ bounded.batch = batch;
3061
+ }
3062
+ if (jsonByteLength(bounded) <= MCP_ERROR_DETAILS_MAX_BYTES) {
3063
+ return bounded;
3064
+ }
3065
+ delete bounded.batch;
3066
+ while (jsonByteLength(bounded) > MCP_ERROR_DETAILS_MAX_BYTES && Array.isArray(bounded.completedResults) && bounded.completedResults.length > 0) {
3067
+ bounded.completedResults = bounded.completedResults.slice(0, -1);
3068
+ bounded.resultsTruncated = true;
3069
+ bounded.omittedResults = sourceResults ? sourceResults.length - bounded.completedResults.length : void 0;
3070
+ }
3071
+ return jsonByteLength(bounded) <= MCP_ERROR_DETAILS_MAX_BYTES ? bounded : { truncated: true, mcpOutputTruncated: true, warning: "Error details were omitted because they exceeded the MCP response budget." };
3072
+ }
2922
3073
  function jsonText(value) {
2923
3074
  return JSON.stringify(value) ?? "null";
2924
3075
  }
3076
+ function parseBrowserExecCode(code) {
3077
+ let parsed;
3078
+ try {
3079
+ parsed = JSON.parse(code);
3080
+ } catch (error) {
3081
+ throw new AppError("SCRIPT_INVALID", "code must be a JSON array of validated browser actions.", { cause: error });
3082
+ }
3083
+ const result = BrowserActionPlanSchema.safeParse(parsed);
3084
+ if (!result.success) {
3085
+ throw new AppError("SCRIPT_INVALID", "code must be a non-empty JSON array of validated browser actions.", {
3086
+ details: { issues: result.error.issues.map((issue) => ({ path: issue.path, message: issue.message })) }
3087
+ });
3088
+ }
3089
+ return result.data;
3090
+ }
2925
3091
  function truncateUtf8(value, maxBytes) {
2926
3092
  const bytes = UTF8_ENCODER.encode(value);
2927
3093
  if (bytes.byteLength <= maxBytes) {
@@ -2992,7 +3158,7 @@ function boundMcpArray(value) {
2992
3158
  warning: "The MCP result exceeded the client record budget; use a narrower selector or a paginated tool."
2993
3159
  };
2994
3160
  }
2995
- function boundMcpOutput(value) {
3161
+ function boundMcpOutput(value, options = {}) {
2996
3162
  if (typeof value === "string") {
2997
3163
  return truncateMcpText(value, MCP_OUTPUT_MAX_BYTES).value;
2998
3164
  }
@@ -3002,7 +3168,7 @@ function boundMcpOutput(value) {
3002
3168
  if (!isRecord2(value)) {
3003
3169
  return value;
3004
3170
  }
3005
- const output = { ...value };
3171
+ let output = { ...value };
3006
3172
  const markOutputTruncated = () => {
3007
3173
  output.mcpOutputTruncated = true;
3008
3174
  };
@@ -3032,7 +3198,9 @@ function boundMcpOutput(value) {
3032
3198
  output.truncated = true;
3033
3199
  }
3034
3200
  capArray("links", MCP_OUTPUT_LINK_LIMIT, "linksTruncated");
3035
- capArray("results", MCP_OUTPUT_RESULT_LIMIT, "resultsTruncated");
3201
+ if (!options.preserveBatchResults) {
3202
+ capArray("results", MCP_OUTPUT_RESULT_LIMIT, "resultsTruncated");
3203
+ }
3036
3204
  capArray("entries", MCP_OUTPUT_ENTRY_LIMIT, "entriesTruncated");
3037
3205
  capArray("interactive", MCP_OUTPUT_INTERACTIVE_LIMIT, "interactiveTruncated");
3038
3206
  capArray("nodes", MCP_OUTPUT_NODE_LIMIT, "nodesTruncated");
@@ -3066,6 +3234,28 @@ function boundMcpOutput(value) {
3066
3234
  if (jsonByteLength(output) <= MCP_OUTPUT_MAX_BYTES) {
3067
3235
  return output;
3068
3236
  }
3237
+ if (options.preserveBatchResults && Array.isArray(output.results)) {
3238
+ const allResults = output.results;
3239
+ const base = { ...output };
3240
+ delete base.results;
3241
+ const retained = [];
3242
+ for (const item of allResults) {
3243
+ const candidate = { ...base, results: [...retained, item] };
3244
+ if (jsonByteLength(candidate) > MCP_OUTPUT_MAX_BYTES - 256) {
3245
+ break;
3246
+ }
3247
+ retained.push(item);
3248
+ }
3249
+ output = {
3250
+ ...base,
3251
+ results: retained,
3252
+ ...retained.length < allResults.length ? { resultsTruncated: true, omittedResults: allResults.length - retained.length } : {},
3253
+ ...retained.length < allResults.length ? { mcpOutputTruncated: true } : {}
3254
+ };
3255
+ if (jsonByteLength(output) <= MCP_OUTPUT_MAX_BYTES) {
3256
+ return output;
3257
+ }
3258
+ }
3069
3259
  for (const key of ["text", "html"]) {
3070
3260
  while (jsonByteLength(output) > MCP_OUTPUT_MAX_BYTES && typeof output[key] === "string" && UTF8_ENCODER.encode(output[key]).byteLength > 4e3) {
3071
3261
  const current = output[key];
@@ -3075,7 +3265,11 @@ function boundMcpOutput(value) {
3075
3265
  markOutputTruncated();
3076
3266
  }
3077
3267
  }
3078
- for (const [key, flag] of [["links", "linksTruncated"], ["results", "resultsTruncated"], ["entries", "entriesTruncated"], ["interactive", "interactiveTruncated"], ["nodes", "nodesTruncated"], ["matches", "matchesTruncated"], ["frames", "framesTruncated"]]) {
3268
+ const arrayBounds = [["links", "linksTruncated"], ["entries", "entriesTruncated"], ["interactive", "interactiveTruncated"], ["nodes", "nodesTruncated"], ["matches", "matchesTruncated"], ["frames", "framesTruncated"]];
3269
+ if (!options.preserveBatchResults) {
3270
+ arrayBounds.push(["results", "resultsTruncated"]);
3271
+ }
3272
+ for (const [key, flag] of arrayBounds) {
3079
3273
  while (jsonByteLength(output) > MCP_OUTPUT_MAX_BYTES && Array.isArray(output[key]) && output[key].length > 1) {
3080
3274
  output[key] = output[key].slice(0, Math.max(1, Math.floor(output[key].length / 2)));
3081
3275
  output[flag] = true;
@@ -3086,7 +3280,7 @@ function boundMcpOutput(value) {
3086
3280
  return output;
3087
3281
  }
3088
3282
  const preserved = {};
3089
- for (const key of ["pageId", "frameId", "snapshotId", "domRevision", "url", "title", "selector", "query", "offset", "hasMore"]) {
3283
+ for (const key of ["pageId", "frameId", "snapshotId", "domRevision", "url", "untrustedUrl", "title", "selector", "query", "offset", "nextOffset", "revision", "hasMore", "totalMatches", "matchesTruncated", "resultsTruncated", "omittedResults"]) {
3090
3284
  const item = output[key];
3091
3285
  if (typeof item === "string") {
3092
3286
  preserved[key] = truncateUtf8(item, 1e3);
@@ -3102,10 +3296,10 @@ function boundMcpOutput(value) {
3102
3296
  warning: "The MCP result exceeded the client record budget; use a narrower selector or a paginated tool."
3103
3297
  };
3104
3298
  }
3105
- function sanitizeMcpOutput(value) {
3106
- const bounded = boundMcpOutput(value);
3299
+ function sanitizeMcpOutput(value, options = {}) {
3300
+ const bounded = boundMcpOutput(value, options);
3107
3301
  const redacted = redactValue(bounded);
3108
- return jsonByteLength(redacted) > MCP_OUTPUT_MAX_BYTES ? boundMcpOutput(redacted) : redacted;
3302
+ return jsonByteLength(redacted) > MCP_OUTPUT_MAX_BYTES ? boundMcpOutput(redacted, options) : redacted;
3109
3303
  }
3110
3304
  async function callTool2(operation, logger) {
3111
3305
  return boundToolError(await callTool(
@@ -3113,6 +3307,14 @@ async function callTool2(operation, logger) {
3113
3307
  (error) => logger?.logger.warn("MCP tool operation failed", safeErrorDiagnostic(error))
3114
3308
  ));
3115
3309
  }
3310
+ async function callBatchTool(operation, logger) {
3311
+ try {
3312
+ return toolResult(sanitizeMcpOutput(await operation(), { preserveBatchResults: true }) ?? null);
3313
+ } catch (error) {
3314
+ logger?.logger.warn("MCP batch operation failed", safeErrorDiagnostic(error));
3315
+ return boundToolError(toolError(error));
3316
+ }
3317
+ }
3116
3318
  async function callVisualTool(operation, logger) {
3117
3319
  try {
3118
3320
  const rawValue = await operation();
@@ -3158,12 +3360,7 @@ function boundToolError(result) {
3158
3360
  retryable: rawError.retryable === true
3159
3361
  };
3160
3362
  if (rawError.details !== void 0) {
3161
- const details = sanitizeMcpOutput(rawError.details);
3162
- error.details = jsonByteLength(details) <= MCP_ERROR_DETAILS_MAX_BYTES ? details : {
3163
- truncated: true,
3164
- mcpOutputTruncated: true,
3165
- warning: "Error details were omitted because they exceeded the MCP response budget."
3166
- };
3363
+ error.details = boundErrorDetails(rawError.details);
3167
3364
  }
3168
3365
  const payload = { ok: false, error };
3169
3366
  return {
@@ -3177,18 +3374,18 @@ function boundToolError(result) {
3177
3374
  init_logger();
3178
3375
 
3179
3376
  // src/server/runtime.ts
3180
- import { chmod, lstat as lstat2, mkdir as mkdir2, open, readFile as readFile2, realpath as realpath2, rename as rename2, unlink as unlink2 } from "node:fs/promises";
3377
+ import { chmod, lstat as lstat2, mkdir as mkdir2, open as open2, readFile as readFile2, realpath as realpath2, rename as rename2, unlink as unlink2 } from "node:fs/promises";
3181
3378
  import { randomUUID as randomUUID2 } from "node:crypto";
3182
3379
  import { basename as basename3, dirname as dirname3, join as join5, resolve as resolve4 } from "node:path";
3183
3380
  import process3 from "node:process";
3184
3381
 
3185
3382
  // src/server/browser/service.ts
3186
3383
  init_errors();
3187
- import { access, lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
3384
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
3385
+ import { constants as fsConstants } from "node:fs";
3188
3386
  import { basename, dirname, extname, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "node:path";
3189
3387
  import { randomUUID } from "node:crypto";
3190
3388
  import { platform } from "node:process";
3191
- import puppeteer from "puppeteer-core";
3192
3389
  init_logger();
3193
3390
 
3194
3391
  // src/server/security.ts
@@ -3438,6 +3635,11 @@ function globMatches(value, glob) {
3438
3635
  }
3439
3636
 
3440
3637
  // src/server/browser/service.ts
3638
+ var puppeteerModulePromise;
3639
+ function loadPuppeteer() {
3640
+ puppeteerModulePromise ??= import("puppeteer-core").then((module) => module.default);
3641
+ return puppeteerModulePromise;
3642
+ }
3441
3643
  var MAX_LOG_ENTRIES = 500;
3442
3644
  var MAX_ACTION_PLAN_STEPS = 100;
3443
3645
  var MAX_QUEUED_OPERATIONS = 64;
@@ -3499,6 +3701,28 @@ var CHALLENGE_BLOCKED_ACTIONS = /* @__PURE__ */ new Set([
3499
3701
  "set_storage",
3500
3702
  "clear_storage"
3501
3703
  ]);
3704
+ var SNAPSHOT_AFTER_ACTIONS = /* @__PURE__ */ new Set([
3705
+ "navigate",
3706
+ "click",
3707
+ "input",
3708
+ "select_dropdown",
3709
+ "scroll",
3710
+ "send_keys",
3711
+ "go_back",
3712
+ "go_forward",
3713
+ "reload"
3714
+ ]);
3715
+ var DOM_MUTATING_ACTIONS = /* @__PURE__ */ new Set([
3716
+ "click",
3717
+ "input",
3718
+ "select_dropdown",
3719
+ "scroll",
3720
+ "scroll_to_bottom",
3721
+ "send_keys",
3722
+ "upload_file",
3723
+ "set_storage",
3724
+ "clear_storage"
3725
+ ]);
3502
3726
  var BrowserService = class {
3503
3727
  constructor(config, policy, logger, dependencies = {}) {
3504
3728
  this.config = config;
@@ -3522,24 +3746,31 @@ var BrowserService = class {
3522
3746
  browserClosePromise;
3523
3747
  connectionSettlementPromise;
3524
3748
  interruptedBrowserShutdown;
3749
+ failedBrowserShutdown;
3525
3750
  browserShutdownFailure = false;
3751
+ recoveryRequired = false;
3752
+ recoveryPromise;
3526
3753
  shutdownController = new AbortController();
3527
3754
  activeOperationController;
3528
3755
  currentPageId;
3529
3756
  sessionGeneration = 0;
3530
3757
  states = /* @__PURE__ */ new Map();
3758
+ configuredDownloadContexts = /* @__PURE__ */ new WeakSet();
3531
3759
  ids = /* @__PURE__ */ new WeakMap();
3532
3760
  targetGuardSessions = /* @__PURE__ */ new Map();
3533
3761
  unguardedTargetSessions = /* @__PURE__ */ new Set();
3534
3762
  pendingTargetGuardSessions = /* @__PURE__ */ new Map();
3763
+ pendingTargetGuardInfos = /* @__PURE__ */ new Map();
3535
3764
  targetGuardUnavailable = false;
3536
3765
  targetGuardConnection;
3537
3766
  targetGuardConnectionListener;
3538
3767
  targetGuardRawConnectionListener;
3768
+ targetGuardDetachedListener;
3539
3769
  targetGuardOriginalEmit;
3540
3770
  targetGuardWrappedEmit;
3541
3771
  operationTail = Promise.resolve();
3542
3772
  queuedOperations = 0;
3773
+ benchmarkCounters = process.env.SMOOTH_OPERATOR_BENCHMARK_COUNTERS === "true" ? { browserOperations: 0, pageLookups: 0, pageEnumerations: 0, pageEvaluations: 0, cdpCommands: 0 } : void 0;
3543
3774
  async close() {
3544
3775
  if (this.closePromise) {
3545
3776
  return this.closePromise;
@@ -3572,11 +3803,19 @@ var BrowserService = class {
3572
3803
  return { ...browserResult, succeeded: browserResult.succeeded && connectionSettled && lateConnectionSettled && interruptedSucceeded };
3573
3804
  }
3574
3805
  connectionStatus() {
3575
- return { connected: Boolean(this.browser), owned: this.ownsBrowser, trackedPages: this.states.size, queuedOperations: this.queuedOperations, currentPageId: this.currentPageId ?? null };
3806
+ return {
3807
+ connected: Boolean(this.browser),
3808
+ owned: this.ownsBrowser,
3809
+ trackedPages: this.states.size,
3810
+ queuedOperations: this.queuedOperations,
3811
+ currentPageId: this.currentPageId ?? null,
3812
+ recoveryRequired: this.recoveryRequired,
3813
+ ...this.benchmarkCounters ? { benchmarkCounters: { ...this.benchmarkCounters } } : {}
3814
+ };
3576
3815
  }
3577
3816
  sessionSummary() {
3578
3817
  const status = this.connectionStatus();
3579
- return { session_id: this.sessionId, active: status.connected, owned: status.owned, trackedPages: status.trackedPages, queuedOperations: status.queuedOperations, currentPageId: status.currentPageId, lastActivityAt: new Date(this.lastActivityAt).toISOString() };
3818
+ return { session_id: this.sessionId, active: status.connected, owned: status.owned, trackedPages: status.trackedPages, queuedOperations: status.queuedOperations, currentPageId: status.currentPageId, recoveryRequired: this.recoveryRequired, lastActivityAt: new Date(this.lastActivityAt).toISOString() };
3580
3819
  }
3581
3820
  async doctor() {
3582
3821
  const discovered = this.config.browser.executablePath ? void 0 : findChromeExecutable();
@@ -3607,7 +3846,24 @@ var BrowserService = class {
3607
3846
  }
3608
3847
  this.sessionGeneration += 1;
3609
3848
  this.activeOperationController?.abort();
3849
+ let interruptedCleanupFailed = false;
3850
+ if (this.interruptedBrowserShutdown) {
3851
+ const cleanup = await settleWithTimeout(this.interruptedBrowserShutdown, SHUTDOWN_CONNECTION_SETTLE_TIMEOUT_MS);
3852
+ if (cleanup === void 0) {
3853
+ this.recoveryRequired = true;
3854
+ return { closed: false, session_id: this.sessionId };
3855
+ }
3856
+ if (cleanup === true) {
3857
+ this.interruptedBrowserShutdown = void 0;
3858
+ } else {
3859
+ interruptedCleanupFailed = true;
3860
+ }
3861
+ }
3610
3862
  const result = await this.closeBrowser();
3863
+ if (interruptedCleanupFailed && result.succeeded) {
3864
+ this.interruptedBrowserShutdown = void 0;
3865
+ }
3866
+ this.recoveryRequired = !result.succeeded;
3611
3867
  return { closed: result.closed, session_id: this.sessionId };
3612
3868
  }
3613
3869
  async closeBrowser() {
@@ -3643,14 +3899,17 @@ var BrowserService = class {
3643
3899
  if (this.connectionPromise === pendingConnection) {
3644
3900
  this.connectionPromise = void 0;
3645
3901
  }
3646
- const browser = this.browser;
3647
- const owned = this.ownsBrowser;
3902
+ const browser = this.browser ?? this.failedBrowserShutdown?.browser;
3903
+ const owned = this.browser ? this.ownsBrowser : this.failedBrowserShutdown?.owned ?? false;
3904
+ this.failedBrowserShutdown = void 0;
3648
3905
  this.detachTargetGuard();
3649
3906
  this.browser = void 0;
3650
3907
  this.ownsBrowser = false;
3651
3908
  this.retireAllStates();
3652
3909
  if (!browser) {
3653
- return { closed: false, owned: false, succeeded: !this.browserShutdownFailure };
3910
+ const succeeded2 = !this.browserShutdownFailure;
3911
+ this.recoveryRequired = !succeeded2;
3912
+ return { closed: false, owned: false, succeeded: succeeded2 };
3654
3913
  }
3655
3914
  let succeeded = true;
3656
3915
  if (owned) {
@@ -3666,8 +3925,11 @@ var BrowserService = class {
3666
3925
  }
3667
3926
  if (!succeeded) {
3668
3927
  this.browserShutdownFailure = true;
3928
+ this.failedBrowserShutdown = { browser, owned };
3929
+ this.recoveryRequired = true;
3669
3930
  } else {
3670
3931
  this.browserShutdownFailure = false;
3932
+ this.recoveryRequired = false;
3671
3933
  }
3672
3934
  return { closed: true, owned, succeeded };
3673
3935
  }
@@ -3680,6 +3942,9 @@ var BrowserService = class {
3680
3942
  const browser = await this.ensureBrowser(signal);
3681
3943
  let pages;
3682
3944
  try {
3945
+ if (this.benchmarkCounters) {
3946
+ this.benchmarkCounters.pageEnumerations += 1;
3947
+ }
3683
3948
  pages = await browser.pages();
3684
3949
  } catch (error) {
3685
3950
  if (!this.isCurrentBrowser(browser, generation)) {
@@ -3733,6 +3998,9 @@ var BrowserService = class {
3733
3998
  return this.withOperationLock(options.signal, (signal) => this.snapshotUnlocked({ ...options, signal }), this.config.browser.actionTimeoutMs, this.config.browser.actionTimeoutMs);
3734
3999
  }
3735
4000
  async snapshotUnlocked(options = {}) {
4001
+ if (this.benchmarkCounters) {
4002
+ this.benchmarkCounters.pageEvaluations += 1;
4003
+ }
3736
4004
  throwIfAborted(options.signal);
3737
4005
  this.assertNoPendingDialog(options.pageId);
3738
4006
  const state = await this.pageState(options.pageId, options.signal);
@@ -3848,13 +4116,12 @@ var BrowserService = class {
3848
4116
  throwIfAborted(options.signal);
3849
4117
  const snapshotId = randomUUID();
3850
4118
  let frameId;
3851
- let screenshot;
3852
- if (options.includeScreenshot) {
3853
- screenshot = await this.screenshotBase64(state.page, options.fullPage ?? false, this.config.browser.maxScreenshotBytes, "png", 80, options.maxDimension);
3854
- }
4119
+ const [title, frames, screenshot] = await Promise.all([
4120
+ frame.title().catch(() => ""),
4121
+ options.includeFrames === "metadata" ? this.listFrames(state) : Promise.resolve(void 0),
4122
+ options.includeScreenshot ? this.screenshotBase64(state.page, options.fullPage ?? false, this.config.browser.maxScreenshotBytes, "png", 80, options.maxDimension) : Promise.resolve(void 0)
4123
+ ]);
3855
4124
  throwIfAborted(options.signal);
3856
- const title = await frame.title().catch(() => "");
3857
- const frames = options.includeFrames === "metadata" ? await this.listFrames(state) : void 0;
3858
4125
  const pageUrl = sanitizeUrl(state.page.url());
3859
4126
  this.assertStateLive(state);
3860
4127
  if (state.domRevision !== domRevisionAtStart || isFrameDetached(frame)) {
@@ -3874,6 +4141,11 @@ var BrowserService = class {
3874
4141
  frameId,
3875
4142
  index: element.index
3876
4143
  }]));
4144
+ state.snapshotInteractive = result.interactive.map(({ signature: _signature, ...element }) => ({
4145
+ ...element,
4146
+ text: wrapUntrustedText("interactive_text", redactSecretPlaceholders(element.text), 500),
4147
+ ...element.ariaLabel ? { ariaLabel: wrapUntrustedText("interactive_label", redactSecretPlaceholders(element.ariaLabel), 500) } : {}
4148
+ }));
3877
4149
  return {
3878
4150
  pageId: state.id,
3879
4151
  frameId,
@@ -3884,11 +4156,7 @@ var BrowserService = class {
3884
4156
  text: wrapUntrustedText("page_text", redactSecretPlaceholders(result.text), maxChars),
3885
4157
  textTruncated: result.textTruncated,
3886
4158
  headings: result.headings.map((heading) => wrapUntrustedText("page_heading", redactSecretPlaceholders(heading), 500)),
3887
- interactive: result.interactive.map(({ signature: _signature, ...element }) => ({
3888
- ...element,
3889
- text: wrapUntrustedText("interactive_text", redactSecretPlaceholders(element.text), 500),
3890
- ...element.ariaLabel ? { ariaLabel: wrapUntrustedText("interactive_label", redactSecretPlaceholders(element.ariaLabel), 500) } : {}
3891
- })),
4159
+ interactive: state.snapshotInteractive,
3892
4160
  interactiveTruncated: result.interactiveTruncated,
3893
4161
  viewport: result.viewport,
3894
4162
  document: result.document,
@@ -3899,6 +4167,12 @@ var BrowserService = class {
3899
4167
  };
3900
4168
  }
3901
4169
  async execute(action, signal) {
4170
+ if (this.benchmarkCounters) {
4171
+ this.benchmarkCounters.browserOperations += 1;
4172
+ }
4173
+ if (this.recoveryRequired && action.action !== "close_browser") {
4174
+ throw new AppError("BROWSER_RECOVERY_REQUIRED", "Browser recovery is required before browser work can continue. Call browser_close_session and retry.", { retryable: true, details: { hint: "Call browser_close_session and retry after cleanup succeeds." } });
4175
+ }
3902
4176
  if (isDialogAction(action)) {
3903
4177
  const pendingState = this.dialogState(action.pageId);
3904
4178
  if (pendingState?.dialogs.length) {
@@ -3910,7 +4184,50 @@ var BrowserService = class {
3910
4184
  }
3911
4185
  const timeoutMs = action.timeoutMs ?? this.config.browser.actionTimeoutMs;
3912
4186
  const budgetMs = action.action === "wait_for_human" ? timeoutMs + 5e3 : timeoutMs;
3913
- return this.withOperationLock(signal, (operationSignal) => this.executeUnlocked(action, operationSignal), budgetMs, budgetMs);
4187
+ return this.withOperationLock(signal, async (operationSignal) => {
4188
+ const result = await this.executeUnlocked(action, operationSignal);
4189
+ if (DOM_MUTATING_ACTIONS.has(action.action)) {
4190
+ this.invalidateActionSnapshot(action, result);
4191
+ }
4192
+ if (!action.includeSnapshot || !SNAPSHOT_AFTER_ACTIONS.has(action.action)) {
4193
+ return result;
4194
+ }
4195
+ return this.attachOptionalSnapshot(action, result, operationSignal);
4196
+ }, budgetMs, budgetMs);
4197
+ }
4198
+ invalidateActionSnapshot(action, result) {
4199
+ const record = result && typeof result === "object" && !Array.isArray(result) ? result : void 0;
4200
+ const resultPageId = typeof record?.pageId === "string" ? record.pageId : typeof record?.openedPageId === "string" ? record.openedPageId : action.pageId ?? this.currentPageId;
4201
+ const state = resultPageId ? this.states.get(resultPageId) : void 0;
4202
+ if (!state || state.disposed) {
4203
+ return;
4204
+ }
4205
+ state.domRevision += 1;
4206
+ state.refs.clear();
4207
+ state.snapshotId = void 0;
4208
+ state.snapshotInteractive = void 0;
4209
+ }
4210
+ async attachOptionalSnapshot(action, result, signal) {
4211
+ const record = result && typeof result === "object" && !Array.isArray(result) ? { ...result } : { result };
4212
+ const pageId = typeof record.openedPageId === "string" ? record.openedPageId : typeof record.pageId === "string" ? record.pageId : this.currentPageId;
4213
+ try {
4214
+ record.snapshot = await this.snapshotUnlocked({ pageId, frameId: action.frameId, maxChars: 8e3, signal });
4215
+ } catch (error) {
4216
+ record.snapshot = null;
4217
+ record.snapshotError = boundedSnapshotError(error);
4218
+ }
4219
+ return record;
4220
+ }
4221
+ /** Execute already-normalized actions while holding one browser-operation lock. */
4222
+ async executeBatch(actions, options = {}, signal) {
4223
+ if (this.benchmarkCounters) {
4224
+ this.benchmarkCounters.browserOperations += actions.length;
4225
+ }
4226
+ if (this.recoveryRequired) {
4227
+ throw new AppError("BROWSER_RECOVERY_REQUIRED", "Browser recovery is required before browser work can continue. Call browser_close_session and retry.", { retryable: true, details: { hint: "Call browser_close_session and retry after cleanup succeeds." } });
4228
+ }
4229
+ const actionCount = actions.length;
4230
+ return this.withOperationLock(signal, (operationSignal) => this.executeBatchUnlocked(actions, options, operationSignal), this.config.browser.actionTimeoutMs * Math.max(1, actionCount), this.config.browser.actionTimeoutMs * Math.max(1, actionCount));
3914
4231
  }
3915
4232
  async executeUnlocked(action, signal) {
3916
4233
  if (!isDialogAction(action) && action.action !== "list_tabs" && action.action !== "close_browser") {
@@ -3980,6 +4297,7 @@ var BrowserService = class {
3980
4297
  case "click": {
3981
4298
  const navigationGeneration = this.beginNavigation(state);
3982
4299
  try {
4300
+ let monitor = { navigated: false, urlChanged: false };
3983
4301
  const coordinateX = action.coordinateX ?? action.coordinate_x;
3984
4302
  const coordinateY = action.coordinateY ?? action.coordinate_y;
3985
4303
  const clickInNewTab = action.newTab ?? action.new_tab;
@@ -4011,7 +4329,7 @@ var BrowserService = class {
4011
4329
  if (coordinateTarget) {
4012
4330
  this.assertClickTargetSafe(coordinateTarget);
4013
4331
  }
4014
- await this.runClickAndMonitor(page, () => page.mouse.click(coordinateX, coordinateY, { button: action.button ?? "left", count: action.clickCount ?? 1 }), signal);
4332
+ monitor = await this.runClickAndMonitor(page, () => page.mouse.click(coordinateX, coordinateY, { button: action.button ?? "left", count: action.clickCount ?? 1 }), signal);
4015
4333
  } else {
4016
4334
  const target = targetForAction(action, "target");
4017
4335
  if (clickInNewTab) {
@@ -4023,10 +4341,10 @@ var BrowserService = class {
4023
4341
  return opened;
4024
4342
  }
4025
4343
  }
4026
- await this.clickTarget(state, target, action.button ?? "left", action.clickCount ?? 1, signal, frame);
4344
+ monitor = await this.clickTarget(state, target, action.button ?? "left", action.clickCount ?? 1, signal, frame);
4027
4345
  }
4028
4346
  await this.throwPendingNavigationError(state, signal, navigationGeneration);
4029
- return { clicked: true, pageId: state.id };
4347
+ return { clicked: true, pageId: state.id, navigated: monitor.navigated, urlChanged: monitor.urlChanged, ...monitor.url ? { url: sanitizeUrl(monitor.url) } : {} };
4030
4348
  } finally {
4031
4349
  if (state.activeNavigationGeneration === navigationGeneration) {
4032
4350
  state.activeNavigationGeneration = void 0;
@@ -4219,15 +4537,8 @@ var BrowserService = class {
4219
4537
  case "wait_for_url": {
4220
4538
  const pattern = requireField(action.url ?? action.value, "url");
4221
4539
  const timeoutMs = action.timeoutMs ?? this.config.browser.actionTimeoutMs;
4222
- const deadline = Date.now() + timeoutMs;
4223
- while (Date.now() <= deadline) {
4224
- throwIfAborted(signal);
4225
- if (globMatches(page.url(), pattern)) {
4226
- return { url: sanitizeUrl(page.url()) };
4227
- }
4228
- await wait(Math.min(100, Math.max(1, deadline - Date.now())), signal);
4229
- }
4230
- throw new AppError("WAIT_TIMEOUT", `The URL did not match '${pattern}' within ${timeoutMs}ms.`, { retryable: true });
4540
+ await this.waitForUrlPattern(page, pattern, timeoutMs, signal);
4541
+ return { url: sanitizeUrl(page.url()) };
4231
4542
  }
4232
4543
  case "wait_for_network_idle":
4233
4544
  await page.waitForNetworkIdle({ idleTime: 500, timeout: action.timeoutMs ?? this.config.browser.actionTimeoutMs, signal });
@@ -4239,12 +4550,12 @@ var BrowserService = class {
4239
4550
  state.networkEnabled = false;
4240
4551
  return { enabled: false };
4241
4552
  case "get_network_log":
4242
- return { entries: state.network.slice(-MAX_LOG_ENTRIES) };
4553
+ return { entries: untrustedLogEntries(state.network.slice(-MAX_LOG_ENTRIES)) };
4243
4554
  case "clear_network_log":
4244
4555
  state.network = [];
4245
4556
  return { cleared: true };
4246
4557
  case "getclear_network_log": {
4247
- const entries = state.network.slice(-MAX_LOG_ENTRIES);
4558
+ const entries = untrustedLogEntries(state.network.slice(-MAX_LOG_ENTRIES));
4248
4559
  state.network = [];
4249
4560
  return { entries, cleared: true };
4250
4561
  }
@@ -4278,6 +4589,11 @@ var BrowserService = class {
4278
4589
  return { ...match, text: wrapUntrustedText("found_text", redactSecretPlaceholders(match.text), 1e3) };
4279
4590
  }
4280
4591
  case "extract": {
4592
+ if (this.benchmarkCounters) {
4593
+ this.benchmarkCounters.pageEvaluations += 1;
4594
+ }
4595
+ const offset = Math.max(0, Math.floor(action.offset ?? 0));
4596
+ const revision = state.domRevision;
4281
4597
  let selector = action.selector ?? action.target ?? (action.index !== void 0 ? `e${action.index + 1}` : void 0);
4282
4598
  if (!selector && action.query) {
4283
4599
  try {
@@ -4294,65 +4610,66 @@ var BrowserService = class {
4294
4610
  }
4295
4611
  const maxChars = Math.min(action.maxChars ?? 4e4, this.config.browser.maxHtmlChars);
4296
4612
  const resolvedSelector = selector ? await this.selectorFor(state, selector, action.frameId) : void 0;
4297
- const extracted = resolvedSelector ? await frame.$eval(resolvedSelector, (element, limit) => {
4613
+ const includeLinks = action.includeLinks === true;
4614
+ const extracted = resolvedSelector ? await frame.$eval(resolvedSelector, (element, options) => {
4298
4615
  const fullText = element.textContent ?? "";
4299
- return { value: fullText.slice(0, limit), truncated: fullText.length > limit };
4300
- }, maxChars).catch((error) => {
4616
+ const value = fullText.slice(options.start, options.start + options.limit);
4617
+ const links = options.includeLinks ? [element, ...Array.from(element.querySelectorAll("a"))].slice(0, 100).map((candidate) => {
4618
+ const rawHref = candidate.href;
4619
+ try {
4620
+ const url = new URL(rawHref);
4621
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
4622
+ return void 0;
4623
+ }
4624
+ url.username = "";
4625
+ url.password = "";
4626
+ return { text: (candidate.textContent ?? "").trim().slice(0, 500), href: url.toString() };
4627
+ } catch {
4628
+ return void 0;
4629
+ }
4630
+ }).filter((link) => Boolean(link)) : void 0;
4631
+ return { value, totalLength: fullText.length, truncated: options.start + value.length < fullText.length, links };
4632
+ }, { start: offset, limit: maxChars, includeLinks }).catch((error) => {
4301
4633
  if (isMissingElementError(error)) {
4302
4634
  throw new AppError("ELEMENT_NOT_FOUND", `No element matched '${resolvedSelector}'.`, { cause: error });
4303
4635
  }
4304
4636
  throw normalizeBrowserOperationError(error, signal);
4305
- }) : await frame.evaluate((limit) => {
4637
+ }) : await frame.evaluate(({ start, limit, includeLinks: includeLinks2 }) => {
4306
4638
  const fullText = document.body?.innerText ?? "";
4307
- return { value: fullText.slice(0, limit), truncated: fullText.length > limit };
4308
- }, maxChars);
4309
- const links = action.includeLinks ? resolvedSelector ? await (async () => {
4310
- try {
4311
- return await frame.$eval(resolvedSelector, (container) => {
4312
- const elements = [container, ...Array.from(container.querySelectorAll("a"))].slice(0, 100);
4313
- return elements.map((element) => {
4314
- const rawHref = element.href;
4315
- try {
4316
- const url = new URL(rawHref);
4317
- if (url.protocol !== "http:" && url.protocol !== "https:") {
4318
- return void 0;
4319
- }
4320
- url.username = "";
4321
- url.password = "";
4322
- return { text: (element.textContent ?? "").trim().slice(0, 500), href: url.toString() };
4323
- } catch {
4324
- return void 0;
4325
- }
4326
- }).filter((link) => Boolean(link));
4327
- });
4328
- } catch (error) {
4329
- if (isMissingElementError(error)) {
4330
- throw new AppError("ELEMENT_NOT_FOUND", `No element matched '${resolvedSelector}'.`, { cause: error });
4331
- }
4332
- throw normalizeBrowserOperationError(error, signal);
4333
- }
4334
- })() : await frame.$$eval("a", (elements) => elements.slice(0, 100).map((element) => {
4335
- const rawHref = element.href;
4336
- try {
4337
- const url = new URL(rawHref);
4338
- if (url.protocol !== "http:" && url.protocol !== "https:") {
4639
+ const value = fullText.slice(start, start + limit);
4640
+ const links = includeLinks2 ? Array.from(document.querySelectorAll("a")).slice(0, 100).map((candidate) => {
4641
+ const rawHref = candidate.href;
4642
+ try {
4643
+ const url = new URL(rawHref);
4644
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
4645
+ return void 0;
4646
+ }
4647
+ url.username = "";
4648
+ url.password = "";
4649
+ return { text: (candidate.textContent ?? "").trim().slice(0, 500), href: url.toString() };
4650
+ } catch {
4339
4651
  return void 0;
4340
4652
  }
4341
- url.username = "";
4342
- url.password = "";
4343
- return { text: (element.textContent ?? "").trim().slice(0, 500), href: url.toString() };
4344
- } catch {
4345
- return void 0;
4346
- }
4347
- }).filter((link) => Boolean(link))) : void 0;
4653
+ }).filter((link) => Boolean(link)) : void 0;
4654
+ return { value, totalLength: fullText.length, truncated: start + value.length < fullText.length, links };
4655
+ }, { start: offset, limit: maxChars, includeLinks });
4656
+ if (state.domRevision !== revision) {
4657
+ throw new AppError("STALE_PAGE_SLICE", "The page changed while its text slice was being collected. Retry with a fresh revision.", { retryable: true, details: { hint: "Capture browser_extract again and use its new revision." } });
4658
+ }
4659
+ const nextOffset = offset + extracted.value.length;
4348
4660
  return {
4661
+ offset,
4662
+ nextOffset,
4663
+ hasMore: extracted.truncated,
4664
+ revision,
4349
4665
  text: wrapUntrustedText("extracted_text", redactSecretPlaceholders(extracted.value), maxChars),
4350
4666
  truncated: extracted.truncated,
4351
4667
  textTruncated: extracted.truncated,
4352
- ...links ? {
4353
- links: links.map((link) => ({
4668
+ ...extracted.links ? {
4669
+ links: extracted.links.map((link) => ({
4354
4670
  text: wrapUntrustedText("extracted_link_text", redactSecretPlaceholders(link.text), 500),
4355
- href: sanitizeUrl(link.href)
4671
+ href: sanitizeUrl(link.href),
4672
+ untrustedUrl: wrapUntrustedText("extracted_link_url", redactSecretPlaceholders(link.href), 4096)
4356
4673
  }))
4357
4674
  } : {}
4358
4675
  };
@@ -4419,20 +4736,26 @@ var BrowserService = class {
4419
4736
  throwIfAborted(signal);
4420
4737
  const selector = await this.selectorFor(state, targetForAction(action, "selector"), action.frameId);
4421
4738
  throwIfAborted(signal);
4422
- const filePath = await this.existingFilePath(requireField(action.filePath, "filePath"));
4739
+ const staged = await this.stageUploadFile(requireField(action.filePath, "filePath"), signal);
4423
4740
  throwIfAborted(signal);
4424
- const input = await frame.$(selector);
4741
+ let input;
4742
+ try {
4743
+ input = await frame.$(selector);
4744
+ } catch (error) {
4745
+ await unlinkIfPresent(staged.path);
4746
+ throw error;
4747
+ }
4425
4748
  if (!input) {
4749
+ await unlinkIfPresent(staged.path);
4426
4750
  throw new AppError("ELEMENT_NOT_FOUND", `No element matched '${selector}'.`);
4427
4751
  }
4428
4752
  try {
4429
- await input.uploadFile(filePath);
4430
- throwIfAborted(signal);
4431
- const fileSize = (await stat(filePath)).size;
4753
+ await input.uploadFile(staged.path);
4432
4754
  throwIfAborted(signal);
4433
- return { uploaded: wrapUntrustedText("uploaded_file_name", redactSecretPlaceholders(basename(filePath)), 512), bytes: Math.min(fileSize, Number.MAX_SAFE_INTEGER) };
4755
+ return { uploaded: wrapUntrustedText("uploaded_file_name", redactSecretPlaceholders(basename(staged.displayName)), 512), bytes: Math.min(staged.size, Number.MAX_SAFE_INTEGER) };
4434
4756
  } finally {
4435
4757
  await input.dispose().catch(() => void 0);
4758
+ await unlinkIfPresent(staged.path);
4436
4759
  }
4437
4760
  }
4438
4761
  case "screenshot": {
@@ -4472,12 +4795,30 @@ var BrowserService = class {
4472
4795
  }));
4473
4796
  }
4474
4797
  case "page_next": {
4798
+ if (this.benchmarkCounters) {
4799
+ this.benchmarkCounters.pageEvaluations += 1;
4800
+ }
4475
4801
  const offset = Math.max(0, Math.floor(action.offset ?? action.amount ?? 0));
4802
+ const revision = action.revision;
4803
+ const revisionAtStart = state.domRevision;
4804
+ if (revision !== void 0 && revision !== revisionAtStart) {
4805
+ throw new AppError("STALE_PAGE_SLICE", "The requested page slice revision is stale. Extract the page again and retry.", { retryable: true, details: { expectedRevision: revisionAtStart, providedRevision: revision, hint: "Capture browser_extract again and use its new revision." } });
4806
+ }
4476
4807
  const maxChars = Math.min(action.maxChars ?? 4e4, this.config.browser.maxHtmlChars);
4477
- const text = await frame.evaluate(({ start, limit }) => document.body?.innerText.slice(start, start + limit + 1) ?? "", { start: offset, limit: maxChars });
4478
- return { offset, text: wrapUntrustedText("page_text", text, maxChars), hasMore: text.length > maxChars };
4808
+ const result = await frame.evaluate(({ start, limit }) => {
4809
+ const fullText = document.body?.innerText ?? "";
4810
+ const text = fullText.slice(start, start + limit);
4811
+ return { text, totalLength: fullText.length, hasMore: start + text.length < fullText.length };
4812
+ }, { start: offset, limit: maxChars });
4813
+ if (state.domRevision !== revisionAtStart) {
4814
+ throw new AppError("STALE_PAGE_SLICE", "The page changed while its text slice was being collected. Retry with a fresh revision.", { retryable: true, details: { hint: "Capture browser_extract again and use its new revision." } });
4815
+ }
4816
+ return { offset, nextOffset: offset + result.text.length, hasMore: result.hasMore, revision: revisionAtStart, text: wrapUntrustedText("page_text", redactSecretPlaceholders(result.text), maxChars) };
4479
4817
  }
4480
4818
  case "search_page": {
4819
+ if (this.benchmarkCounters) {
4820
+ this.benchmarkCounters.pageEvaluations += 1;
4821
+ }
4481
4822
  const query = requireField(action.query ?? action.text, "query");
4482
4823
  const matches = await frame.evaluate((needle) => {
4483
4824
  const text = document.body?.innerText ?? "";
@@ -4485,13 +4826,19 @@ var BrowserService = class {
4485
4826
  const target = needle.toLowerCase();
4486
4827
  const output = [];
4487
4828
  let index = lower.indexOf(target);
4829
+ let totalMatches = 0;
4488
4830
  while (index >= 0 && output.length < 20) {
4489
4831
  output.push(text.slice(Math.max(0, index - 120), Math.min(text.length, index + needle.length + 120)));
4832
+ totalMatches += 1;
4833
+ index = lower.indexOf(target, index + target.length);
4834
+ }
4835
+ while (index >= 0) {
4836
+ totalMatches += 1;
4490
4837
  index = lower.indexOf(target, index + target.length);
4491
4838
  }
4492
- return output;
4839
+ return { matches: output, totalMatches };
4493
4840
  }, query);
4494
- return { query, matches: matches.map((match) => wrapUntrustedText("page_match", match, 500)) };
4841
+ return { query, matches: matches.matches.map((match) => wrapUntrustedText("page_match", redactSecretPlaceholders(match), 500)), totalMatches: matches.totalMatches, matchesTruncated: matches.totalMatches > matches.matches.length };
4495
4842
  }
4496
4843
  case "find_elements": {
4497
4844
  const selector = targetForAction(action, "selector");
@@ -4515,8 +4862,12 @@ var BrowserService = class {
4515
4862
  omittedAttributes: element.omittedAttributes
4516
4863
  }));
4517
4864
  }
4518
- case "list_interactive":
4865
+ case "list_interactive": {
4866
+ if (state.snapshotId && state.snapshotInteractive) {
4867
+ return state.snapshotInteractive;
4868
+ }
4519
4869
  return (await this.snapshotUnlocked({ pageId: state.id, maxChars: 1e3, signal })).interactive;
4870
+ }
4520
4871
  case "list_frames":
4521
4872
  return this.listFrames(state);
4522
4873
  case "accessibility_snapshot":
@@ -4533,7 +4884,7 @@ var BrowserService = class {
4533
4884
  case "evaluate": {
4534
4885
  const code = requireField(action.code ?? action.expression, "code");
4535
4886
  const value = await frame.evaluate((source) => (0, eval)(source), code);
4536
- return redactValue(value);
4887
+ return sanitizeEvaluateResult(value);
4537
4888
  }
4538
4889
  case "hover":
4539
4890
  await frame.hover(await this.selectorFor(state, targetForAction(action, "target"), action.frameId));
@@ -4694,47 +5045,57 @@ var BrowserService = class {
4694
5045
  if (!Array.isArray(parsed) || parsed.length === 0 || parsed.length > MAX_ACTION_PLAN_STEPS) {
4695
5046
  throw new AppError("SCRIPT_INVALID", `The script must be a non-empty JSON array of at most ${MAX_ACTION_PLAN_STEPS} actions.`);
4696
5047
  }
5048
+ const validation = BrowserActionPlanSchema.safeParse(parsed);
5049
+ if (!validation.success) {
5050
+ throw new AppError("SCRIPT_INVALID", "A batch item is not a valid browser action.", {
5051
+ details: { issues: validation.error.issues.map((issue) => ({ path: issue.path, message: issue.message })) }
5052
+ });
5053
+ }
5054
+ return this.executeBatchUnlocked(validation.data, { confirmDestructive }, signal);
5055
+ }
5056
+ async executeBatchUnlocked(actions, options, signal) {
4697
5057
  const results = [];
4698
- for (const [index, candidate] of parsed.entries()) {
4699
- const action = this.parseAction(candidate);
5058
+ for (const [index, candidate] of actions.entries()) {
5059
+ const action = candidate;
4700
5060
  if (action.action === "run_script") {
4701
5061
  throw new AppError("SCRIPT_INVALID", "Nested run_script actions are not allowed.");
4702
5062
  }
4703
- if (action.action === "close_browser" && index !== parsed.length - 1) {
5063
+ if (action.action === "close_browser" && index !== actions.length - 1) {
4704
5064
  throw new AppError("SCRIPT_INVALID", "close_browser must be the final action in a batch.", {
4705
- details: { batch: { failedIndex: index, completedActions: index, failedAction: action.action } }
5065
+ details: batchFailureDetails(index, action.action, results)
4706
5066
  });
4707
5067
  }
4708
5068
  if (action.action === "screenshot") {
4709
5069
  throw new AppError("SCRIPT_INVALID", "Screenshots must be requested with browser_screenshot so the image is returned as MCP image content.");
4710
5070
  }
4711
- if (!confirmDestructive && isDestructiveBatchAction(action.action)) {
4712
- throw new AppError("DESTRUCTIVE_CONFIRMATION_REQUIRED", `Action '${action.action}' must be executed separately or with confirmDestructive=true.`, { retryable: true, details: { batch: { failedIndex: index, completedActions: index, failedAction: action.action } } });
5071
+ if (!options.confirmDestructive && isDestructiveBatchAction(action.action)) {
5072
+ throw new AppError("DESTRUCTIVE_CONFIRMATION_REQUIRED", `Action '${action.action}' must be executed separately or with confirmDestructive=true.`, { retryable: true, details: { hint: "Set confirmDestructive=true or run the action separately.", ...batchFailureDetails(index, action.action, results) } });
4713
5073
  }
4714
5074
  try {
4715
- results.push(await this.executeUnlocked(action, signal));
5075
+ const result = await this.executeUnlocked({ ...action, includeSnapshot: false }, signal);
5076
+ if (DOM_MUTATING_ACTIONS.has(action.action)) {
5077
+ this.invalidateActionSnapshot(action, result);
5078
+ }
5079
+ results.push(result);
4716
5080
  } catch (error) {
4717
5081
  const normalized = asAppError(normalizeBrowserOperationError(error, signal));
4718
5082
  throw new AppError(normalized.code, normalized.message, {
4719
5083
  retryable: normalized.retryable,
4720
- details: {
4721
- ...normalized.details,
4722
- batch: { failedIndex: index, completedActions: index, failedAction: action.action }
4723
- },
5084
+ details: { ...normalized.details, ...batchFailureDetails(index, action.action, results) },
4724
5085
  cause: error
4725
5086
  });
4726
5087
  }
4727
5088
  }
4728
- return { results };
4729
- }
4730
- parseAction(value) {
4731
- const result = BrowserActionSchema.safeParse(value);
4732
- if (!result.success) {
4733
- throw new AppError("SCRIPT_INVALID", "A batch item is not a valid browser action.", {
4734
- details: { issues: result.error.issues.map((issue) => issue.message) }
4735
- });
5089
+ const output = { results };
5090
+ if (options.includeSnapshot) {
5091
+ try {
5092
+ output.snapshot = await this.snapshotUnlocked({ pageId: this.currentPageId, maxChars: 8e3, signal });
5093
+ } catch (error) {
5094
+ output.snapshot = null;
5095
+ output.snapshotError = boundedSnapshotError(error);
5096
+ }
4736
5097
  }
4737
- return result.data;
5098
+ return output;
4738
5099
  }
4739
5100
  async ensureBrowser(signal) {
4740
5101
  if (this.shuttingDown) {
@@ -4743,6 +5104,9 @@ var BrowserService = class {
4743
5104
  if (this.config.browser.mode === "disabled") {
4744
5105
  throw new AppError("BROWSER_DISABLED", "Browser control is disabled by configuration.");
4745
5106
  }
5107
+ if (this.recoveryRequired) {
5108
+ throw new AppError("BROWSER_RECOVERY_REQUIRED", "Browser recovery is required before browser work can continue. Call browser_close_session and retry.", { retryable: true, details: { hint: "Call browser_close_session and retry after cleanup succeeds." } });
5109
+ }
4746
5110
  throwIfAborted(signal);
4747
5111
  if (this.browserClosePromise) {
4748
5112
  await awaitWithAbort(this.browserClosePromise, signal);
@@ -4907,11 +5271,17 @@ var BrowserService = class {
4907
5271
  }
4908
5272
  });
4909
5273
  }
4910
- launch(options) {
4911
- return (this.dependencies.launch ?? puppeteer.launch)(options);
5274
+ async launch(options) {
5275
+ if (this.dependencies.launch) {
5276
+ return this.dependencies.launch(options);
5277
+ }
5278
+ return (await loadPuppeteer()).launch(options);
4912
5279
  }
4913
- connect(options) {
4914
- return (this.dependencies.connect ?? puppeteer.connect)(options);
5280
+ async connect(options) {
5281
+ if (this.dependencies.connect) {
5282
+ return this.dependencies.connect(options);
5283
+ }
5284
+ return (await loadPuppeteer()).connect(options);
4915
5285
  }
4916
5286
  async probeManagedEndpoint() {
4917
5287
  const userDataDir = this.config.browser.userDataDir;
@@ -4948,12 +5318,30 @@ var BrowserService = class {
4948
5318
  }
4949
5319
  }
4950
5320
  async pageState(pageId, signal) {
5321
+ if (this.benchmarkCounters) {
5322
+ this.benchmarkCounters.pageLookups += 1;
5323
+ }
4951
5324
  const generation = this.lifecycleGeneration;
4952
5325
  throwIfAborted(signal);
4953
5326
  const browser = await this.ensureBrowser(signal);
4954
5327
  throwIfAborted(signal);
5328
+ const requestedId = pageId ? this.resolvePageId(pageId) : this.currentPageId;
5329
+ const tracked = requestedId ? this.states.get(requestedId) : void 0;
5330
+ if (tracked && tracked.lifecycleGeneration === generation && !tracked.disposed && !isPageClosed(tracked.page)) {
5331
+ try {
5332
+ await this.configurePage(tracked, signal);
5333
+ this.assertStateLive(tracked);
5334
+ return tracked;
5335
+ } catch (error) {
5336
+ await this.disposeStalePageState(tracked);
5337
+ throw error;
5338
+ }
5339
+ }
4955
5340
  let pages;
4956
5341
  try {
5342
+ if (this.benchmarkCounters) {
5343
+ this.benchmarkCounters.pageEnumerations += 1;
5344
+ }
4957
5345
  pages = await browser.pages();
4958
5346
  } catch (error) {
4959
5347
  if (!this.isCurrentBrowser(browser, generation)) {
@@ -5096,13 +5484,17 @@ var BrowserService = class {
5096
5484
  }
5097
5485
  const session = this.pendingTargetGuardSessions.get(event.sessionId) ?? getCdpSession(targetConnection, event.sessionId);
5098
5486
  this.pendingTargetGuardSessions.delete(event.sessionId);
5099
- if (!isTopLevelPageTarget(event.targetInfo)) {
5487
+ this.pendingTargetGuardInfos.delete(event.sessionId);
5488
+ if (!isGuardableTarget(event.targetInfo)) {
5100
5489
  return;
5101
5490
  }
5102
5491
  const autoAttached = isAutoAttachedTarget(targetConnection, event.targetInfo.targetId);
5103
5492
  if (autoAttached !== true) {
5104
5493
  if (autoAttached === void 0 && event.sessionId) {
5105
5494
  this.unguardedTargetSessions.add(event.sessionId);
5495
+ if (targetConnection.send) {
5496
+ void targetConnection.send("Target.closeTarget", { targetId: event.targetInfo.targetId }).catch(() => void 0);
5497
+ }
5106
5498
  void sendSessionCommand(session, "Page.close").catch(() => void 0);
5107
5499
  this.logger.warn("New browser target guard could not determine attachment ownership");
5108
5500
  }
@@ -5110,15 +5502,33 @@ var BrowserService = class {
5110
5502
  }
5111
5503
  if (!session) {
5112
5504
  this.unguardedTargetSessions.add(event.sessionId);
5505
+ if (targetConnection.send) {
5506
+ void targetConnection.send("Target.closeTarget", { targetId: event.targetInfo.targetId }).catch(() => void 0);
5507
+ }
5113
5508
  this.logger.warn("New browser target guard could not find its CDP session");
5114
5509
  return;
5115
5510
  }
5116
- void this.guardTargetSession(session).catch((error) => {
5511
+ void this.guardTargetSession(session, event.targetInfo).catch((error) => {
5117
5512
  this.logger.warn("New browser target guard failed", { error: String(error) });
5118
5513
  });
5119
5514
  };
5515
+ const detachedListener = (value) => {
5516
+ if (!isRecordValue(value) || typeof value.sessionId !== "string") {
5517
+ return;
5518
+ }
5519
+ const guard = this.targetGuardSessions.get(value.sessionId);
5520
+ if (guard) {
5521
+ guard.released = true;
5522
+ removeCdpListener(guard.session, "Fetch.requestPaused", guard.requestPausedListener);
5523
+ removeCdpListener(guard.session, "disconnected", guard.disconnectedListener);
5524
+ this.targetGuardSessions.delete(value.sessionId);
5525
+ }
5526
+ this.pendingTargetGuardSessions.delete(value.sessionId);
5527
+ this.pendingTargetGuardInfos.delete(value.sessionId);
5528
+ };
5120
5529
  targetConnection.on("sessionattached", sessionListener);
5121
5530
  targetConnection.on("Target.attachedToTarget", rawListener);
5531
+ targetConnection.on("Target.detachedFromTarget", detachedListener);
5122
5532
  const originalEmit = targetConnection.emit;
5123
5533
  if (originalEmit) {
5124
5534
  const wrappedEmit = (event, value) => {
@@ -5139,17 +5549,28 @@ var BrowserService = class {
5139
5549
  this.targetGuardConnection = targetConnection;
5140
5550
  this.targetGuardConnectionListener = sessionListener;
5141
5551
  this.targetGuardRawConnectionListener = rawListener;
5552
+ this.targetGuardDetachedListener = detachedListener;
5553
+ if (targetConnection.send) {
5554
+ void targetConnection.send("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch((error) => {
5555
+ this.targetGuardUnavailable = true;
5556
+ this.logger.warn("Browser target auto-attachment could not be enabled", { error: String(error) });
5557
+ });
5558
+ }
5142
5559
  }
5143
5560
  detachTargetGuard() {
5144
5561
  const connection = this.targetGuardConnection;
5145
5562
  const listener = this.targetGuardConnectionListener;
5146
5563
  const rawListener = this.targetGuardRawConnectionListener;
5564
+ const detachedListener = this.targetGuardDetachedListener;
5147
5565
  if (connection && listener && connection.off) {
5148
5566
  try {
5149
5567
  connection.off("sessionattached", listener);
5150
5568
  if (rawListener) {
5151
5569
  connection.off("Target.attachedToTarget", rawListener);
5152
5570
  }
5571
+ if (detachedListener) {
5572
+ connection.off("Target.detachedFromTarget", detachedListener);
5573
+ }
5153
5574
  } catch {
5154
5575
  }
5155
5576
  }
@@ -5162,26 +5583,31 @@ var BrowserService = class {
5162
5583
  this.targetGuardConnection = void 0;
5163
5584
  this.targetGuardConnectionListener = void 0;
5164
5585
  this.targetGuardRawConnectionListener = void 0;
5586
+ this.targetGuardDetachedListener = void 0;
5165
5587
  this.targetGuardOriginalEmit = void 0;
5166
5588
  this.targetGuardWrappedEmit = void 0;
5167
5589
  this.targetGuardUnavailable = false;
5168
5590
  this.pendingTargetGuardSessions.clear();
5591
+ this.pendingTargetGuardInfos.clear();
5169
5592
  for (const guard of this.targetGuardSessions.values()) {
5170
5593
  guard.released = true;
5171
5594
  removeCdpListener(guard.session, "Fetch.requestPaused", guard.requestPausedListener);
5172
5595
  removeCdpListener(guard.session, "disconnected", guard.disconnectedListener);
5596
+ restoreGuardSend(guard);
5173
5597
  void guard.session.send("Fetch.disable").catch(() => void 0);
5174
5598
  }
5175
5599
  this.targetGuardSessions.clear();
5176
5600
  this.unguardedTargetSessions.clear();
5177
5601
  }
5178
- async guardTargetSession(session) {
5602
+ async guardTargetSession(session, targetInfo) {
5179
5603
  const sessionId = session.id();
5180
5604
  if (this.targetGuardSessions.has(sessionId) || this.shuttingDown) {
5181
5605
  return;
5182
5606
  }
5183
5607
  const guard = {
5184
5608
  session,
5609
+ targetId: targetInfo?.targetId ?? sessionId,
5610
+ targetType: targetInfo?.type ?? "page",
5185
5611
  requestPausedListener: () => void 0,
5186
5612
  disconnectedListener: () => void 0,
5187
5613
  enabled: false,
@@ -5199,26 +5625,69 @@ var BrowserService = class {
5199
5625
  guard.disconnectedListener = () => {
5200
5626
  removeCdpListener(session, "Fetch.requestPaused", guard.requestPausedListener);
5201
5627
  removeCdpListener(session, "disconnected", guard.disconnectedListener);
5628
+ restoreGuardSend(guard);
5202
5629
  this.targetGuardSessions.delete(sessionId);
5203
5630
  this.unguardedTargetSessions.delete(sessionId);
5204
5631
  };
5205
5632
  this.targetGuardSessions.set(sessionId, guard);
5206
5633
  addCdpListener(session, "Fetch.requestPaused", guard.requestPausedListener);
5207
5634
  addCdpListener(session, "disconnected", guard.disconnectedListener);
5635
+ const sessionWithSend = session;
5636
+ const originalSend = sessionWithSend.send.bind(sessionWithSend);
5637
+ const countedSend = async (method, params) => {
5638
+ if (this.benchmarkCounters) {
5639
+ this.benchmarkCounters.cdpCommands += 1;
5640
+ }
5641
+ return originalSend(method, params);
5642
+ };
5643
+ let releaseDebugger;
5644
+ let rejectDebugger;
5645
+ const debuggerReady = new Promise((resolve6, reject) => {
5646
+ releaseDebugger = resolve6;
5647
+ rejectDebugger = reject;
5648
+ });
5649
+ void debuggerReady.catch(() => void 0);
5650
+ const wrappedSend = async (method, params) => {
5651
+ if (method === "Runtime.runIfWaitingForDebugger") {
5652
+ await debuggerReady;
5653
+ }
5654
+ return countedSend(method, params);
5655
+ };
5656
+ guard.originalSend = originalSend;
5657
+ guard.wrappedSend = wrappedSend;
5658
+ try {
5659
+ if (this.targetGuardConnection?.send) {
5660
+ sessionWithSend.send = wrappedSend;
5661
+ } else {
5662
+ releaseDebugger();
5663
+ }
5664
+ } catch {
5665
+ this.logger.warn("Browser target debugger resume could not be gated");
5666
+ }
5208
5667
  try {
5209
- await session.send("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] });
5668
+ await countedSend("Fetch.enable", { patterns: [{ urlPattern: "*", requestStage: "Request" }] });
5210
5669
  guard.enabled = true;
5670
+ releaseDebugger();
5211
5671
  } catch (error) {
5672
+ rejectDebugger(error);
5212
5673
  guard.released = true;
5213
5674
  removeCdpListener(session, "Fetch.requestPaused", guard.requestPausedListener);
5214
5675
  removeCdpListener(session, "disconnected", guard.disconnectedListener);
5676
+ restoreGuardSend(guard);
5215
5677
  this.targetGuardSessions.delete(sessionId);
5216
5678
  this.unguardedTargetSessions.add(sessionId);
5217
- await session.send("Page.close").catch(() => void 0);
5679
+ await this.closeGuardedTarget(guard);
5218
5680
  this.logger.warn("New browser target could not be guarded", { error: String(error) });
5219
5681
  throw error;
5220
5682
  }
5221
5683
  }
5684
+ async closeGuardedTarget(guard) {
5685
+ const connection = this.targetGuardConnection;
5686
+ if (connection?.send) {
5687
+ await connection.send("Target.closeTarget", { targetId: guard.targetId }).catch(() => void 0);
5688
+ }
5689
+ await guard.session.send("Page.close").catch(() => void 0);
5690
+ }
5222
5691
  async handleTargetGuardRequest(guard, event) {
5223
5692
  if (guard.released || !guard.enabled || !isRecordValue(event)) {
5224
5693
  return;
@@ -5235,8 +5704,10 @@ var BrowserService = class {
5235
5704
  guard.requestIds.add(requestId);
5236
5705
  let allowed = false;
5237
5706
  try {
5238
- if (requestUrl === "about:blank" || requestUrl.startsWith("data:") || requestUrl.startsWith("blob:")) {
5707
+ if (/^about:blank(?:#.*)?$/i.test(requestUrl)) {
5239
5708
  allowed = true;
5709
+ } else if (requestUrl.startsWith("data:") || requestUrl.startsWith("blob:")) {
5710
+ allowed = guard.targetType === "service_worker" || guard.targetType === "shared_worker";
5240
5711
  } else if (/^wss?:\/\//i.test(requestUrl)) {
5241
5712
  await this.policy.assertNavigationAllowedAsync(requestUrl.replace(/^ws/i, "http"));
5242
5713
  allowed = true;
@@ -5244,7 +5715,7 @@ var BrowserService = class {
5244
5715
  await this.policy.assertNavigationAllowedAsync(requestUrl);
5245
5716
  allowed = true;
5246
5717
  } else if (requestUrl) {
5247
- allowed = true;
5718
+ allowed = false;
5248
5719
  }
5249
5720
  } catch (error) {
5250
5721
  this.logger.warn("New browser target request blocked", { url: sanitizeUrl(requestUrl), code: error instanceof AppError ? error.code : "URL_BLOCKED" });
@@ -5302,6 +5773,7 @@ var BrowserService = class {
5302
5773
  guard.released = true;
5303
5774
  removeCdpListener(guard.session, "Fetch.requestPaused", guard.requestPausedListener);
5304
5775
  removeCdpListener(guard.session, "disconnected", guard.disconnectedListener);
5776
+ restoreGuardSend(guard);
5305
5777
  this.targetGuardSessions.delete(sessionId);
5306
5778
  }
5307
5779
  async prepareTarget(target) {
@@ -5390,6 +5862,7 @@ var BrowserService = class {
5390
5862
  state.disposed = true;
5391
5863
  this.removePageListeners(state);
5392
5864
  state.refs.clear();
5865
+ state.snapshotInteractive = void 0;
5393
5866
  state.snapshotId = void 0;
5394
5867
  state.dialogs.length = 0;
5395
5868
  state.navigationError = void 0;
@@ -5520,7 +5993,10 @@ var BrowserService = class {
5520
5993
  if (!context.setDownloadBehavior) {
5521
5994
  throw new AppError("DOWNLOAD_CONFIGURATION_FAILED", "The connected browser does not expose context download behavior.");
5522
5995
  }
5523
- await context.setDownloadBehavior({ policy: "allow", downloadPath });
5996
+ if (!this.configuredDownloadContexts.has(context)) {
5997
+ await context.setDownloadBehavior({ policy: "allow", downloadPath });
5998
+ this.configuredDownloadContexts.add(context);
5999
+ }
5524
6000
  state.downloadConfigured = true;
5525
6001
  } catch {
5526
6002
  const client = await state.page.createCDPSession();
@@ -5578,7 +6054,7 @@ var BrowserService = class {
5578
6054
  mainFrameNavigation = isFrameNavigation && requestFrame === state.page.mainFrame();
5579
6055
  navigationGeneration = mainFrameNavigation ? state.activeNavigationGeneration : void 0;
5580
6056
  requestUrl = request.url();
5581
- if (requestUrl === "about:blank") {
6057
+ if (/^about:blank(?:#.*)?$/i.test(requestUrl)) {
5582
6058
  await request.continue();
5583
6059
  return;
5584
6060
  }
@@ -5590,14 +6066,12 @@ var BrowserService = class {
5590
6066
  return;
5591
6067
  }
5592
6068
  if (!/^https?:\/\//i.test(requestUrl)) {
5593
- if (isFrameNavigation) {
5594
- throw new AppError("URL_BLOCKED", "Non-HTTP browser navigations are disabled by policy.");
5595
- }
5596
6069
  if (/^wss?:\/\//i.test(requestUrl)) {
5597
6070
  await this.policy.assertNavigationAllowedAsync(requestUrl.replace(/^ws/i, "http"));
6071
+ await request.continue();
6072
+ return;
5598
6073
  }
5599
- await request.continue();
5600
- return;
6074
+ throw new AppError("URL_BLOCKED", "Unsupported browser URL schemes are disabled by policy.");
5601
6075
  }
5602
6076
  await this.policy.assertNavigationAllowedAsync(requestUrl);
5603
6077
  await request.continue();
@@ -5705,6 +6179,7 @@ var BrowserService = class {
5705
6179
  state.domRevision += 1;
5706
6180
  state.snapshotId = void 0;
5707
6181
  state.refs.clear();
6182
+ state.snapshotInteractive = void 0;
5708
6183
  try {
5709
6184
  const mainFrame = state.page.mainFrame();
5710
6185
  if (frame === mainFrame && mainFrame.url() !== "about:blank") {
@@ -5723,6 +6198,7 @@ var BrowserService = class {
5723
6198
  state.domRevision += 1;
5724
6199
  state.snapshotId = void 0;
5725
6200
  state.refs.clear();
6201
+ state.snapshotInteractive = void 0;
5726
6202
  };
5727
6203
  state.frameAttachedListener = frameAttachedListener;
5728
6204
  state.page.on("frameattached", frameAttachedListener);
@@ -5733,6 +6209,7 @@ var BrowserService = class {
5733
6209
  state.domRevision += 1;
5734
6210
  state.snapshotId = void 0;
5735
6211
  state.refs.clear();
6212
+ state.snapshotInteractive = void 0;
5736
6213
  FRAME_IDS.delete(frame);
5737
6214
  };
5738
6215
  state.frameDetachedListener = frameDetachedListener;
@@ -5887,6 +6364,7 @@ var BrowserService = class {
5887
6364
  const currentSignature = await frame.$eval(stored.selector, (element) => {
5888
6365
  const htmlElement = element;
5889
6366
  const anchor = element.closest("a");
6367
+ const rect = element.getBoundingClientRect();
5890
6368
  return [
5891
6369
  element.tagName.toLowerCase(),
5892
6370
  element.getAttribute("role") ?? "",
@@ -5894,10 +6372,10 @@ var BrowserService = class {
5894
6372
  htmlElement.type ?? "",
5895
6373
  (htmlElement.innerText || element.getAttribute("value") || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 500),
5896
6374
  anchor?.href ?? "",
5897
- Math.round(element.getBoundingClientRect().x),
5898
- Math.round(element.getBoundingClientRect().y),
5899
- Math.round(element.getBoundingClientRect().width),
5900
- Math.round(element.getBoundingClientRect().height)
6375
+ Math.round(rect.x),
6376
+ Math.round(rect.y),
6377
+ Math.round(rect.width),
6378
+ Math.round(rect.height)
5901
6379
  ].join("");
5902
6380
  }).catch(() => void 0);
5903
6381
  if (!currentSignature || currentSignature !== stored.signature) {
@@ -5917,6 +6395,48 @@ var BrowserService = class {
5917
6395
  throw error;
5918
6396
  }
5919
6397
  }
6398
+ async clickSnapshotRef(state, target, frame) {
6399
+ const normalized = target.trim();
6400
+ const ref = normalized.startsWith("ref:") ? normalized.slice(4) : normalized;
6401
+ const stored = state.refs.get(ref);
6402
+ if (!/^e\d+$/.test(ref) || !stored || stored.snapshotId !== state.snapshotId) {
6403
+ throw new AppError("STALE_REFERENCE", `Element reference '${ref}' is stale. Capture a fresh browser snapshot before acting.`, { retryable: true });
6404
+ }
6405
+ const effectiveFrameId = framePath(frame);
6406
+ if (effectiveFrameId !== stored.frameId) {
6407
+ throw new AppError("FRAME_MISMATCH", `Reference '${ref}' belongs to frame '${stored.frameId}', not '${effectiveFrameId}'.`, { retryable: true });
6408
+ }
6409
+ const evaluated = await frame.$eval(stored.selector, (element) => {
6410
+ const clickable = element.closest("a,button,input,select,textarea,[role=button]") ?? element;
6411
+ const htmlElement = clickable;
6412
+ const anchor = clickable.closest("a");
6413
+ const rect = clickable.getBoundingClientRect();
6414
+ return {
6415
+ signature: [
6416
+ element.tagName.toLowerCase(),
6417
+ element.getAttribute("role") ?? "",
6418
+ element.getAttribute("aria-label") ?? "",
6419
+ htmlElement.type ?? "",
6420
+ (htmlElement.innerText || element.getAttribute("value") || element.textContent || "").replace(/\s+/g, " ").trim().slice(0, 500),
6421
+ anchor?.href ?? "",
6422
+ Math.round(rect.x),
6423
+ Math.round(rect.y),
6424
+ Math.round(rect.width),
6425
+ Math.round(rect.height)
6426
+ ].join(""),
6427
+ tag: clickable.tagName.toLowerCase(),
6428
+ type: htmlElement.type?.toLowerCase() ?? "",
6429
+ role: clickable.getAttribute("role") ?? "",
6430
+ label: [clickable.textContent, clickable.getAttribute("aria-label"), clickable.getAttribute("title"), htmlElement.value].filter(Boolean).join(" ").replace(/\s+/g, " ").trim().slice(0, 200),
6431
+ href: anchor?.href ?? clickable.href ?? clickable.getAttribute("href") ?? void 0,
6432
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
6433
+ };
6434
+ }).catch(() => void 0);
6435
+ if (!evaluated || evaluated.signature !== stored.signature) {
6436
+ throw new AppError("STALE_REFERENCE", `Element reference '${ref}' no longer identifies the same element. Capture a fresh browser snapshot before acting.`, { retryable: true });
6437
+ }
6438
+ return { selector: stored.selector, descriptor: evaluated };
6439
+ }
5920
6440
  async openLinkInNewTab(state, target, signal) {
5921
6441
  const browser = await this.ensureBrowser(signal);
5922
6442
  if (this.targetGuardUnavailable) {
@@ -6095,15 +6615,69 @@ var BrowserService = class {
6095
6615
  });
6096
6616
  throwIfAborted(signal);
6097
6617
  }
6618
+ async waitForUrlPattern(page, pattern, timeoutMs, signal) {
6619
+ throwIfAborted(signal);
6620
+ if (globMatches(page.url(), pattern)) {
6621
+ return;
6622
+ }
6623
+ if (typeof page.on !== "function" || typeof page.off !== "function") {
6624
+ const deadline = Date.now() + timeoutMs;
6625
+ while (Date.now() <= deadline) {
6626
+ throwIfAborted(signal);
6627
+ if (globMatches(page.url(), pattern)) {
6628
+ return;
6629
+ }
6630
+ await wait(Math.min(100, Math.max(1, deadline - Date.now())), signal);
6631
+ }
6632
+ throw new AppError("WAIT_TIMEOUT", `The URL did not match '${pattern}' within ${timeoutMs}ms.`, { retryable: true });
6633
+ }
6634
+ await new Promise((resolvePromise, reject) => {
6635
+ let settled = false;
6636
+ const cleanup = () => {
6637
+ clearTimeout(timer);
6638
+ page.off("framenavigated", onNavigated);
6639
+ signal?.removeEventListener("abort", onAbort);
6640
+ };
6641
+ const finish = (callback) => {
6642
+ if (settled) return;
6643
+ settled = true;
6644
+ cleanup();
6645
+ callback();
6646
+ };
6647
+ const onNavigated = () => {
6648
+ try {
6649
+ if (globMatches(page.url(), pattern)) {
6650
+ finish(resolvePromise);
6651
+ }
6652
+ } catch (error) {
6653
+ finish(() => reject(error));
6654
+ }
6655
+ };
6656
+ const onAbort = () => finish(() => reject(new AppError("CANCELLED", "The browser action was cancelled.")));
6657
+ const timer = setTimeout(() => finish(() => reject(new AppError("WAIT_TIMEOUT", `The URL did not match '${pattern}' within ${timeoutMs}ms.`, { retryable: true }))), timeoutMs);
6658
+ page.on("framenavigated", onNavigated);
6659
+ signal?.addEventListener("abort", onAbort, { once: true });
6660
+ onNavigated();
6661
+ });
6662
+ }
6098
6663
  async clickTarget(state, target, button, clickCount, signal, frame = state.page.mainFrame()) {
6099
6664
  let selector;
6100
- try {
6101
- selector = await this.selectorFor(state, target, framePath(frame));
6102
- } catch (error) {
6103
- if (shouldPropagateTargetError(error)) {
6104
- throw error;
6665
+ let clickDescriptor;
6666
+ const normalizedTarget = target.trim();
6667
+ const ref = normalizedTarget.startsWith("ref:") ? normalizedTarget.slice(4) : normalizedTarget;
6668
+ if (/^e\d+$/.test(ref)) {
6669
+ const resolved = await this.clickSnapshotRef(state, normalizedTarget, frame);
6670
+ selector = resolved.selector;
6671
+ clickDescriptor = resolved.descriptor;
6672
+ } else {
6673
+ try {
6674
+ selector = await this.selectorFor(state, target, framePath(frame));
6675
+ } catch (error) {
6676
+ if (shouldPropagateTargetError(error)) {
6677
+ throw error;
6678
+ }
6679
+ selector = void 0;
6105
6680
  }
6106
- selector = void 0;
6107
6681
  }
6108
6682
  if (selector) {
6109
6683
  const resolved = await frame.$(selector);
@@ -6113,36 +6687,31 @@ var BrowserService = class {
6113
6687
  }
6114
6688
  }
6115
6689
  if (selector) {
6116
- await this.assertClickTargetSafe(await frame.$eval(selector, (element) => {
6690
+ clickDescriptor ??= await frame.$eval(selector, (element) => {
6117
6691
  const clickable = element.closest("a,button,input,select,textarea,[role=button]") ?? element;
6118
6692
  const htmlElement = clickable;
6693
+ const anchor = clickable.closest("a");
6119
6694
  return {
6120
6695
  tag: clickable.tagName.toLowerCase(),
6121
6696
  type: htmlElement.type?.toLowerCase() ?? "",
6122
6697
  role: clickable.getAttribute("role") ?? "",
6123
- label: [clickable.textContent, clickable.getAttribute("aria-label"), clickable.getAttribute("title"), htmlElement.value].filter(Boolean).join(" ").replace(/\s+/g, " ").trim().slice(0, 200)
6698
+ label: [clickable.textContent, clickable.getAttribute("aria-label"), clickable.getAttribute("title"), htmlElement.value].filter(Boolean).join(" ").replace(/\s+/g, " ").trim().slice(0, 200),
6699
+ href: anchor?.href ?? clickable.href ?? clickable.getAttribute("href") ?? void 0,
6700
+ rect: (() => {
6701
+ const rect = clickable.getBoundingClientRect();
6702
+ return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
6703
+ })()
6124
6704
  };
6125
- }));
6126
- await this.assertElementNavigationAllowed(state, selector, frame);
6127
- await this.clickElement(state, frame, selector, button, clickCount, signal);
6128
- return;
6705
+ });
6706
+ this.assertClickTargetSafe(clickDescriptor);
6707
+ if (clickDescriptor.href) {
6708
+ await this.assertNavigationUrl(frame.url() || state.page.url(), clickDescriptor.href);
6709
+ }
6710
+ return this.clickElement(state, frame, selector, button, clickCount, signal);
6129
6711
  }
6130
6712
  if (button !== "left") {
6131
6713
  throw new AppError("INVALID_ACTION", "Exact visible-text clicks support only the left mouse button; use a selector or coordinates for other buttons.");
6132
6714
  }
6133
- const href = await frame.evaluate((needle) => {
6134
- const candidates = Array.from(document.querySelectorAll("body *"));
6135
- const element = candidates.find((candidate) => {
6136
- const htmlElement = candidate;
6137
- return (htmlElement.innerText || candidate.textContent || "").trim() === needle;
6138
- });
6139
- const clickable = element?.closest("a,button,input,select,textarea,[role=button],[onclick]");
6140
- const anchor = clickable?.closest("a");
6141
- return anchor?.href ?? clickable?.getAttribute("href") ?? null;
6142
- }, target);
6143
- if (href) {
6144
- await this.assertNavigationUrl(state.page.url(), href);
6145
- }
6146
6715
  const targetBox = await frame.evaluate((needle) => {
6147
6716
  const candidates = Array.from(document.querySelectorAll("body *"));
6148
6717
  const element = candidates.find((candidate) => {
@@ -6155,6 +6724,7 @@ var BrowserService = class {
6155
6724
  }
6156
6725
  const rect = clickable.getBoundingClientRect();
6157
6726
  const htmlElement = clickable;
6727
+ const anchor = clickable.closest("a");
6158
6728
  return {
6159
6729
  x: rect.x + rect.width / 2,
6160
6730
  y: rect.y + rect.height / 2,
@@ -6163,18 +6733,23 @@ var BrowserService = class {
6163
6733
  tag: clickable.tagName.toLowerCase(),
6164
6734
  type: htmlElement.type?.toLowerCase() ?? "",
6165
6735
  role: clickable.getAttribute("role") ?? "",
6166
- label: [clickable.textContent, clickable.getAttribute("aria-label"), clickable.getAttribute("title"), htmlElement.value].filter(Boolean).join(" ").replace(/\s+/g, " ").trim().slice(0, 200)
6736
+ label: [clickable.textContent, clickable.getAttribute("aria-label"), clickable.getAttribute("title"), htmlElement.value].filter(Boolean).join(" ").replace(/\s+/g, " ").trim().slice(0, 200),
6737
+ href: anchor?.href ?? clickable.getAttribute("href") ?? void 0
6167
6738
  };
6168
6739
  }, target);
6169
6740
  if (!targetBox || targetBox.width <= 0 || targetBox.height <= 0) {
6170
6741
  throw new AppError("ELEMENT_NOT_FOUND", `No clickable element matched '${target.slice(0, 200)}'.`);
6171
6742
  }
6172
6743
  this.assertClickTargetSafe(targetBox);
6744
+ if (targetBox.href) {
6745
+ await this.assertNavigationUrl(state.page.url(), targetBox.href);
6746
+ }
6173
6747
  if (frame !== state.page.mainFrame()) {
6174
6748
  throw new AppError("FRAME_ACTION_UNSUPPORTED", "Exact-text clicks in child frames require a selector or snapshot ref.");
6175
6749
  }
6176
- await this.runClickAndMonitor(state.page, () => state.page.mouse.click(targetBox.x, targetBox.y, { button: "left", count: clickCount }), signal);
6750
+ const monitor = await this.runClickAndMonitor(state.page, () => state.page.mouse.click(targetBox.x, targetBox.y, { button: "left", count: clickCount }), signal);
6177
6751
  await this.throwPendingNavigationError(state, signal);
6752
+ return monitor;
6178
6753
  }
6179
6754
  assertClickTargetSafe(target) {
6180
6755
  if (target.tag === "input" && target.type === "file") {
@@ -6194,33 +6769,35 @@ var BrowserService = class {
6194
6769
  const onDialog = () => {
6195
6770
  dialogObserved = true;
6196
6771
  removeDialogListener?.();
6197
- resolve6();
6772
+ resolve6(null);
6198
6773
  };
6199
6774
  state.page.on("dialog", onDialog);
6200
6775
  removeDialogListener = () => state.page.off("dialog", onDialog);
6201
6776
  });
6202
6777
  const click = this.runClickAndMonitor(state.page, () => frame.click(selector, { button, count: clickCount }), signal).then(
6203
- () => false,
6778
+ (result) => result,
6204
6779
  (error) => {
6205
6780
  removeDialogListener?.();
6206
6781
  if (dialogObserved) {
6207
- return true;
6782
+ return { navigated: false, urlChanged: false };
6208
6783
  }
6209
6784
  throw error;
6210
6785
  }
6211
6786
  );
6212
- const openedDialog = await Promise.race([click, dialogOpened.then(() => true)]);
6787
+ const openedDialog = await Promise.race([click, dialogOpened]);
6213
6788
  removeDialogListener?.();
6214
- if (openedDialog) {
6789
+ if (openedDialog === null) {
6215
6790
  void click.catch((error) => {
6216
6791
  this.logger.debug("Browser click completed after dialog resolution", { pageId: state.id, error: String(error) });
6217
6792
  });
6218
6793
  throwIfAborted(signal);
6219
- return;
6794
+ return { navigated: false, urlChanged: false };
6220
6795
  }
6796
+ return openedDialog;
6221
6797
  }
6222
6798
  async runClickAndMonitor(page, trigger, signal) {
6223
6799
  throwIfAborted(signal);
6800
+ const beforeUrl = typeof page.url === "function" ? page.url() : "";
6224
6801
  let navigated = false;
6225
6802
  const onFrameNavigated = (frame) => {
6226
6803
  try {
@@ -6233,25 +6810,18 @@ var BrowserService = class {
6233
6810
  page.on("framenavigated", onFrameNavigated);
6234
6811
  try {
6235
6812
  await trigger();
6236
- await wait(150, signal);
6813
+ await wait(50, signal);
6237
6814
  if (navigated) {
6238
6815
  await page.waitForNetworkIdle({ idleTime: 100, timeout: Math.min(this.config.browser.actionTimeoutMs, 1e3), signal }).catch(() => {
6239
6816
  throwIfAborted(signal);
6240
6817
  });
6241
6818
  }
6819
+ const url = typeof page.url === "function" ? page.url() : "";
6820
+ return { navigated, urlChanged: url !== beforeUrl, url };
6242
6821
  } finally {
6243
6822
  page.off("framenavigated", onFrameNavigated);
6244
6823
  }
6245
6824
  }
6246
- async assertElementNavigationAllowed(state, selector, frame = state.page.mainFrame()) {
6247
- const href = await frame.$eval(selector, (element) => {
6248
- const anchor = element.closest("a");
6249
- return anchor?.href ?? element.href ?? element.getAttribute("href");
6250
- }).catch(() => void 0);
6251
- if (href) {
6252
- await this.assertNavigationUrl(frame.url() || state.page.url(), href);
6253
- }
6254
- }
6255
6825
  async assertCurrentPageAllowed(page) {
6256
6826
  const url = page.url();
6257
6827
  if (url === "about:blank") {
@@ -6569,12 +7139,59 @@ var BrowserService = class {
6569
7139
  throw new AppError("DOWNLOADS_UNAVAILABLE", "Downloaded files could not be listed.", { cause: error });
6570
7140
  });
6571
7141
  }
6572
- async existingFilePath(rawPath) {
7142
+ async stageUploadFile(rawPath, signal) {
6573
7143
  const candidate = this.policy.assertFilePath(rawPath, { mustExist: true });
6574
- await access(candidate);
6575
- const resolvedPath = await realpath(candidate);
6576
- this.policy.assertFilePath(resolvedPath, { mustExist: true });
6577
- return resolvedPath;
7144
+ const before = await lstat(candidate).catch((error) => {
7145
+ throw new AppError("FILE_PATH_BLOCKED", "The upload source does not exist or cannot be resolved safely.", { cause: error });
7146
+ });
7147
+ if (before.isSymbolicLink()) {
7148
+ throw new AppError("FILE_PATH_BLOCKED", "The upload source must not be a symbolic link.");
7149
+ }
7150
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
7151
+ let sourceHandle;
7152
+ let stagingPath;
7153
+ try {
7154
+ sourceHandle = await open(candidate, fsConstants.O_RDONLY | noFollow);
7155
+ const opened = await sourceHandle.stat();
7156
+ if (!opened.isFile()) {
7157
+ throw new AppError("FILE_PATH_BLOCKED", "The upload source must be a regular file.");
7158
+ }
7159
+ const after = await lstat(candidate);
7160
+ if (after.isSymbolicLink() || !sameFileIdentity(opened, after)) {
7161
+ throw new AppError("FILE_PATH_BLOCKED", "The upload source changed while it was being opened.", { retryable: true });
7162
+ }
7163
+ throwIfAborted(signal);
7164
+ const stagingDirectory = join3(this.config.dataDir, "upload-staging");
7165
+ await mkdir(stagingDirectory, { recursive: true, mode: 448 });
7166
+ stagingPath = join3(stagingDirectory, `.upload-${randomUUID()}`);
7167
+ const stagingHandle = await open(stagingPath, "wx", 384);
7168
+ try {
7169
+ for await (const chunk of sourceHandle.createReadStream({ autoClose: false })) {
7170
+ throwIfAborted(signal);
7171
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
7172
+ let offset = 0;
7173
+ while (offset < buffer.byteLength) {
7174
+ const written = await stagingHandle.write(buffer, offset, buffer.byteLength - offset, null);
7175
+ if (written.bytesWritten <= 0) {
7176
+ throw new AppError("FILE_PATH_BLOCKED", "The upload source could not be staged safely.");
7177
+ }
7178
+ offset += written.bytesWritten;
7179
+ }
7180
+ }
7181
+ await stagingHandle.sync();
7182
+ } finally {
7183
+ await stagingHandle.close().catch(() => void 0);
7184
+ }
7185
+ throwIfAborted(signal);
7186
+ return { path: stagingPath, displayName: basename(candidate), size: opened.size };
7187
+ } catch (error) {
7188
+ if (stagingPath) {
7189
+ await unlinkIfPresent(stagingPath);
7190
+ }
7191
+ throw error instanceof AppError ? error : new AppError("FILE_PATH_BLOCKED", "The upload source could not be staged safely.", { cause: error });
7192
+ } finally {
7193
+ await sourceHandle?.close().catch(() => void 0);
7194
+ }
6578
7195
  }
6579
7196
  async outputFilePath(rawPath) {
6580
7197
  const candidate = this.policy.assertFilePath(rawPath);
@@ -6601,25 +7218,55 @@ var BrowserService = class {
6601
7218
  interruptBrowserOperation() {
6602
7219
  this.lifecycleGeneration += 1;
6603
7220
  this.detachTargetGuard();
7221
+ this.interruptedBrowserShutdown = void 0;
6604
7222
  const browser = this.browser;
6605
7223
  const owned = this.ownsBrowser;
6606
7224
  this.browser = void 0;
6607
7225
  this.ownsBrowser = false;
6608
7226
  this.retireAllStates();
6609
7227
  if (browser) {
7228
+ this.failedBrowserShutdown = { browser, owned };
6610
7229
  this.interruptedBrowserShutdown = closeConnectedBrowser(browser, owned, this.logger);
6611
7230
  void this.interruptedBrowserShutdown.then((succeeded) => {
6612
7231
  if (!succeeded) {
6613
7232
  this.browserShutdownFailure = true;
7233
+ this.recoveryRequired = true;
6614
7234
  } else {
6615
7235
  this.browserShutdownFailure = false;
7236
+ if (this.failedBrowserShutdown?.browser === browser) {
7237
+ this.failedBrowserShutdown = void 0;
7238
+ }
6616
7239
  }
6617
7240
  });
6618
7241
  }
6619
7242
  }
7243
+ async recoverAfterAbort(operationPromise) {
7244
+ if (this.recoveryPromise) {
7245
+ return this.recoveryPromise;
7246
+ }
7247
+ const recovery = (async () => {
7248
+ const settled = await promiseSettledWithin(operationPromise, 250);
7249
+ if (settled) {
7250
+ return;
7251
+ }
7252
+ this.interruptBrowserOperation();
7253
+ const shutdown = this.interruptedBrowserShutdown;
7254
+ const succeeded = shutdown ? await settleWithTimeout(shutdown, SHUTDOWN_CONNECTION_SETTLE_TIMEOUT_MS) : true;
7255
+ if (succeeded !== true) {
7256
+ this.recoveryRequired = true;
7257
+ }
7258
+ })();
7259
+ this.recoveryPromise = recovery;
7260
+ void recovery.finally(() => {
7261
+ if (this.recoveryPromise === recovery) {
7262
+ this.recoveryPromise = void 0;
7263
+ }
7264
+ }).catch(() => void 0);
7265
+ return recovery;
7266
+ }
6620
7267
  async withOperationLock(signal, operation, queueTimeoutMs = this.config.browser.actionTimeoutMs, operationTimeoutMs) {
6621
7268
  if (this.queuedOperations >= MAX_QUEUED_OPERATIONS) {
6622
- throw new AppError("BROWSER_QUEUE_FULL", "The browser action queue is full; wait for an active operation to finish and retry.", { retryable: true });
7269
+ throw new AppError("BROWSER_QUEUE_FULL", "The browser action queue is full; wait for an active operation to finish and retry.", { retryable: true, details: { hint: "Wait for the active browser operation to finish, then retry." } });
6623
7270
  }
6624
7271
  this.queuedOperations += 1;
6625
7272
  const requestSessionGeneration = this.sessionGeneration;
@@ -6644,16 +7291,20 @@ var BrowserService = class {
6644
7291
  this.activeOperationController = operationController;
6645
7292
  const operationSignal = combineSignals(queueSignal, operationController.signal) ?? operationController.signal;
6646
7293
  let operationTimedOut = false;
7294
+ let abortRequested = false;
7295
+ let recoveryAfterAbort;
6647
7296
  const operationBudgetMs = operationTimeoutMs === void 0 ? void 0 : Math.max(1, Math.floor(operationTimeoutMs) - Math.max(0, Date.now() - requestStartedAt));
6648
- let operationSettled = false;
6649
7297
  let removeAbortListener;
6650
7298
  let rejectAbort;
6651
7299
  const abortPromise = new Promise((_, reject) => {
6652
7300
  rejectAbort = reject;
6653
7301
  const onAbort = () => {
7302
+ if (abortRequested) {
7303
+ return;
7304
+ }
7305
+ abortRequested = true;
6654
7306
  const timedOut = operationTimedOut;
6655
7307
  operationController.abort();
6656
- this.interruptBrowserOperation();
6657
7308
  reject(timedOut ? new AppError("BROWSER_TIMEOUT", `The browser operation exceeded its ${Math.max(1, Math.floor(operationTimeoutMs ?? 0))}ms action deadline.`, { retryable: true, details: { timeoutMs: Math.max(1, Math.floor(operationTimeoutMs ?? 0)) } }) : new AppError("CANCELLED", "The browser action was cancelled."));
6658
7309
  };
6659
7310
  if (queueSignal?.aborted) {
@@ -6666,21 +7317,19 @@ var BrowserService = class {
6666
7317
  const deadlineTimer = operationTimeoutMs === void 0 ? void 0 : setTimeout(() => {
6667
7318
  if (!queueSignal?.aborted) {
6668
7319
  operationTimedOut = true;
6669
- operationController.abort();
6670
- this.interruptBrowserOperation();
6671
- rejectAbort(new AppError("BROWSER_TIMEOUT", `The browser operation exceeded its ${Math.max(1, Math.floor(operationTimeoutMs ?? 0))}ms action deadline.`, { retryable: true, details: { timeoutMs: Math.max(1, Math.floor(operationTimeoutMs ?? 0)) } }));
7320
+ if (!abortRequested) {
7321
+ abortRequested = true;
7322
+ operationController.abort();
7323
+ rejectAbort(new AppError("BROWSER_TIMEOUT", `The browser operation exceeded its ${Math.max(1, Math.floor(operationTimeoutMs ?? 0))}ms action deadline.`, { retryable: true, details: { timeoutMs: Math.max(1, Math.floor(operationTimeoutMs ?? 0)) } }));
7324
+ }
6672
7325
  }
6673
7326
  }, operationBudgetMs);
6674
7327
  this.lastActivityAt = Date.now();
6675
7328
  operationPromise = Promise.resolve().then(() => operation(operationSignal));
6676
- void operationPromise.then(
6677
- () => {
6678
- operationSettled = true;
6679
- },
6680
- () => {
6681
- operationSettled = true;
6682
- }
6683
- );
7329
+ void operationPromise.catch(() => void 0);
7330
+ if (abortRequested) {
7331
+ recoveryAfterAbort = this.recoverAfterAbort(operationPromise);
7332
+ }
6684
7333
  try {
6685
7334
  const result = await Promise.race([operationPromise, abortPromise]);
6686
7335
  throwIfAborted(operationSignal);
@@ -6699,9 +7348,11 @@ var BrowserService = class {
6699
7348
  if (this.activeOperationController === operationController) {
6700
7349
  this.activeOperationController = void 0;
6701
7350
  }
6702
- if (!operationSettled && operationPromise) {
7351
+ if (abortRequested && operationPromise) {
6703
7352
  deferRelease = true;
6704
- void operationPromise.then(() => release(), () => release());
7353
+ recoveryAfterAbort ??= this.recoverAfterAbort(operationPromise);
7354
+ await recoveryAfterAbort;
7355
+ deferRelease = false;
6705
7356
  }
6706
7357
  }
6707
7358
  } finally {
@@ -6761,6 +7412,30 @@ async function closePageSafely(page) {
6761
7412
  await Promise.resolve().then(() => page.close()).catch(() => void 0);
6762
7413
  }
6763
7414
  async function waitForElementState(frame, selector, state, timeoutMs, signal) {
7415
+ if (typeof frame.waitForFunction === "function") {
7416
+ try {
7417
+ await frame.waitForFunction(
7418
+ (targetSelector, desiredState) => {
7419
+ const element = document.querySelector(targetSelector);
7420
+ const visible = Boolean(element && (() => {
7421
+ const style = window.getComputedStyle(element);
7422
+ const rect = element.getBoundingClientRect();
7423
+ return style.display !== "none" && style.visibility !== "hidden" && Number.parseFloat(style.opacity || "1") > 0 && rect.width > 0 && rect.height > 0;
7424
+ })());
7425
+ return desiredState === "attached" ? Boolean(element) : desiredState === "detached" ? !element : desiredState === "hidden" ? !visible : visible;
7426
+ },
7427
+ { timeout: timeoutMs, signal },
7428
+ selector,
7429
+ state
7430
+ );
7431
+ return;
7432
+ } catch (error) {
7433
+ if (isPuppeteerTimeoutError(error)) {
7434
+ throw new AppError("WAIT_TIMEOUT", `The selector '${selector.slice(0, 200)}' did not become ${state} within ${timeoutMs}ms.`, { retryable: true, cause: error });
7435
+ }
7436
+ throw error;
7437
+ }
7438
+ }
6764
7439
  const deadline = Date.now() + timeoutMs;
6765
7440
  while (true) {
6766
7441
  throwIfAborted(signal);
@@ -6817,6 +7492,43 @@ function normalizeBrowserOperationError(error, signal) {
6817
7492
  }
6818
7493
  return error;
6819
7494
  }
7495
+ function batchFailureDetails(failedIndex, failedAction, completedResults) {
7496
+ const boundedResults = [];
7497
+ let bytes = 2;
7498
+ for (const result of completedResults) {
7499
+ let resultBytes = 0;
7500
+ try {
7501
+ resultBytes = new TextEncoder().encode(JSON.stringify(result)).byteLength;
7502
+ } catch {
7503
+ resultBytes = Number.POSITIVE_INFINITY;
7504
+ }
7505
+ if (bytes + resultBytes + (boundedResults.length ? 1 : 0) > 6e3) {
7506
+ break;
7507
+ }
7508
+ boundedResults.push(result);
7509
+ bytes += resultBytes + (boundedResults.length > 1 ? 1 : 0);
7510
+ }
7511
+ return {
7512
+ failedIndex,
7513
+ failedAction,
7514
+ completedActions: failedIndex,
7515
+ completedResults: boundedResults,
7516
+ ...boundedResults.length < completedResults.length ? { resultsTruncated: true, omittedResults: completedResults.length - boundedResults.length } : {},
7517
+ batch: {
7518
+ failedIndex,
7519
+ failedAction,
7520
+ completedActions: failedIndex
7521
+ }
7522
+ };
7523
+ }
7524
+ function boundedSnapshotError(error) {
7525
+ const normalized = asAppError(error);
7526
+ return {
7527
+ code: normalized.code.slice(0, 200),
7528
+ message: normalized.message.slice(0, 1e3),
7529
+ retryable: normalized.retryable
7530
+ };
7531
+ }
6820
7532
  function isPageClosed(page) {
6821
7533
  try {
6822
7534
  return page.isClosed();
@@ -6852,7 +7564,8 @@ function trimLog(entries) {
6852
7564
  function untrustedLogEntries(entries) {
6853
7565
  return entries.map((entry) => ({
6854
7566
  ...entry,
6855
- ...entry.text ? { text: wrapUntrustedText("browser_log", redactSecretPlaceholders(entry.text), 2e3) } : {}
7567
+ ...entry.text ? { text: wrapUntrustedText("browser_log", redactSecretPlaceholders(entry.text), 2e3) } : {},
7568
+ ...entry.url ? { untrustedUrl: wrapUntrustedText("browser_log_url", redactSecretPlaceholders(entry.url), 4096) } : {}
6856
7569
  }));
6857
7570
  }
6858
7571
  function sanitizeStorageResult(value) {
@@ -6888,6 +7601,19 @@ function sanitizeStorageResult(value) {
6888
7601
  }
6889
7602
  return result;
6890
7603
  }
7604
+ function sanitizeEvaluateResult(value) {
7605
+ const redacted = redactValue(value);
7606
+ if (typeof value === "string") {
7607
+ return wrapUntrustedText("evaluate_result", redactSecretPlaceholders(String(redacted)), 2e4);
7608
+ }
7609
+ if (redacted && typeof redacted === "object" && !Array.isArray(redacted)) {
7610
+ const record = { ...redacted };
7611
+ const metadataKey = Object.hasOwn(record, "untrustedSource") ? "__untrustedSource" : "untrustedSource";
7612
+ record[metadataKey] = "page";
7613
+ return record;
7614
+ }
7615
+ return { value: redacted, untrustedSource: "page" };
7616
+ }
6891
7617
  function boundAccessibilityNodes(nodes, maxChars) {
6892
7618
  const limit = Number.isFinite(maxChars) ? Math.max(2, Math.floor(maxChars)) : 2;
6893
7619
  const bounded = [];
@@ -7104,6 +7830,11 @@ async function settlesWithinTimeout(promise, timeoutMs) {
7104
7830
  await settleWithTimeout(promise, timeoutMs).catch(() => void 0);
7105
7831
  return settled;
7106
7832
  }
7833
+ async function promiseSettledWithin(promise, timeoutMs) {
7834
+ const settledMarker = /* @__PURE__ */ Symbol("settled");
7835
+ const result = await settleWithTimeout(promise.then(() => settledMarker, () => settledMarker), timeoutMs);
7836
+ return result === settledMarker;
7837
+ }
7107
7838
  async function wait(milliseconds, signal) {
7108
7839
  if (milliseconds <= 0) {
7109
7840
  return;
@@ -7188,6 +7919,9 @@ function redactWebSocketEndpoint(value) {
7188
7919
  function isMissingFile(error) {
7189
7920
  return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
7190
7921
  }
7922
+ function sameFileIdentity(left, right) {
7923
+ return left.dev === right.dev && left.ino === right.ino;
7924
+ }
7191
7925
  function boundedScreenshotDimension(value) {
7192
7926
  return Number.isFinite(value) && value > 0 ? Math.min(Math.round(value), 1e6) : 0;
7193
7927
  }
@@ -7210,8 +7944,8 @@ function parseTargetAttachedEvent(value) {
7210
7944
  }
7211
7945
  };
7212
7946
  }
7213
- function isTopLevelPageTarget(targetInfo) {
7214
- return targetInfo.type === "page" || targetInfo.type === "tab";
7947
+ function isGuardableTarget(targetInfo) {
7948
+ return targetInfo.type === "page" || targetInfo.type === "tab" || targetInfo.type === "service_worker" || targetInfo.type === "shared_worker";
7215
7949
  }
7216
7950
  function isAutoAttachedTarget(connection, targetId) {
7217
7951
  if (typeof connection.isAutoAttached !== "function") {
@@ -7248,6 +7982,18 @@ function removeCdpListener(session, event, listener) {
7248
7982
  } catch {
7249
7983
  }
7250
7984
  }
7985
+ function restoreGuardSend(guard) {
7986
+ if (!guard.originalSend || !guard.wrappedSend) {
7987
+ return;
7988
+ }
7989
+ const session = guard.session;
7990
+ if (session.send === guard.wrappedSend) {
7991
+ try {
7992
+ session.send = guard.originalSend;
7993
+ } catch {
7994
+ }
7995
+ }
7996
+ }
7251
7997
 
7252
7998
  // src/server/runtime.ts
7253
7999
  init_errors();
@@ -7661,6 +8407,7 @@ function parseResults(html, maxResults, maxChars, baseUrl) {
7661
8407
  results.push({
7662
8408
  title: wrapUntrustedText("research_title", boundedTitle, 500),
7663
8409
  url,
8410
+ untrustedUrl: wrapUntrustedText("research_url", redactSecretPlaceholders(url), 4096),
7664
8411
  snippet: wrapUntrustedText("research_snippet", boundedSnippet, 4e3)
7665
8412
  });
7666
8413
  }
@@ -7767,6 +8514,31 @@ var ServerRuntime = class _ServerRuntime {
7767
8514
  browser;
7768
8515
  research;
7769
8516
  closePromise;
8517
+ /** True when this session's config implies ownership of the shared managed
8518
+ * browser profile (and therefore of its lease). */
8519
+ get profileLeaseRequired() {
8520
+ const ownsBrowserProcess = this.config.browser.mode !== "disabled" && (this.config.browser.mode === "managed" || this.config.browser.mode === "launch" || this.config.browser.autoLaunch && Boolean(this.config.browser.executablePath));
8521
+ return Boolean(ownsBrowserProcess && this.config.browser.userDataDir);
8522
+ }
8523
+ /** Browser operations must hold the profile lease before touching the
8524
+ * managed browser. Acquisition is lazy so concurrent harness sessions stay
8525
+ * connected while idle; only genuinely simultaneous browsing conflicts,
8526
+ * and that surfaces as a retryable tool error instead of a dead server. */
8527
+ async ensureBrowserProfileLease() {
8528
+ if (!this.profileLeaseRequired || this.browserProfileLease || !this.config.browser.userDataDir) {
8529
+ return;
8530
+ }
8531
+ try {
8532
+ await ensurePrivateDirectory(this.config.browser.userDataDir);
8533
+ this.browserProfileLease = await acquireBrowserProfileLease(this.config.browser.userDataDir);
8534
+ this.logger.info("Acquired browser profile lease on demand");
8535
+ } catch (error) {
8536
+ if (error instanceof AppError && (error.code === "BROWSER_PROFILE_IN_USE" || error.code === "BROWSER_PROFILE_LOCK_FAILED")) {
8537
+ throw new AppError("BROWSER_PROFILE_IN_USE", "Another SmoothOperator session currently owns the managed browser profile. Retry when that session closes, or switch one of them to connect mode.", { retryable: true });
8538
+ }
8539
+ throw error;
8540
+ }
8541
+ }
7770
8542
  static async create(config) {
7771
8543
  let browserProfileLease;
7772
8544
  try {
@@ -7774,9 +8546,15 @@ var ServerRuntime = class _ServerRuntime {
7774
8546
  await ensurePrivateDirectory(join5(config.dataDir, "downloads"));
7775
8547
  await ensurePrivateDirectory(join5(config.dataDir, "files"));
7776
8548
  const ownsBrowserProcess = config.browser.mode !== "disabled" && (config.browser.mode === "managed" || config.browser.mode === "launch" || config.browser.autoLaunch && Boolean(config.browser.executablePath));
7777
- if (ownsBrowserProcess && config.browser.userDataDir) {
8549
+ const needsProfileLease = Boolean(ownsBrowserProcess && config.browser.userDataDir);
8550
+ if (needsProfileLease && config.browser.userDataDir) {
7778
8551
  await ensurePrivateDirectory(config.browser.userDataDir);
7779
- browserProfileLease = await acquireBrowserProfileLease(config.browser.userDataDir);
8552
+ browserProfileLease = await acquireBrowserProfileLease(config.browser.userDataDir).catch((error) => {
8553
+ if (error instanceof AppError && (error.code === "BROWSER_PROFILE_IN_USE" || error.code === "BROWSER_PROFILE_LOCK_FAILED")) {
8554
+ return void 0;
8555
+ }
8556
+ throw error;
8557
+ });
7780
8558
  }
7781
8559
  const canonicalDataDir = await realpath2(config.dataDir);
7782
8560
  const runtime = new _ServerRuntime({ ...config, dataDir: canonicalDataDir }, browserProfileLease);
@@ -7810,12 +8588,19 @@ var ServerRuntime = class _ServerRuntime {
7810
8588
  await this.closePromise;
7811
8589
  }
7812
8590
  async run(action, signal) {
8591
+ await this.ensureBrowserProfileLease();
7813
8592
  return this.browser.execute(action, signal);
7814
8593
  }
8594
+ async runBatch(actions, options = {}, signal) {
8595
+ await this.ensureBrowserProfileLease();
8596
+ return this.browser.executeBatch(actions, options, signal);
8597
+ }
7815
8598
  async snapshot(options, signal) {
8599
+ await this.ensureBrowserProfileLease();
7816
8600
  return this.browser.snapshot({ ...options, signal });
7817
8601
  }
7818
8602
  async listTabs(signal) {
8603
+ await this.ensureBrowserProfileLease();
7819
8604
  return this.browser.listTabs(signal);
7820
8605
  }
7821
8606
  listSessions() {
@@ -7828,6 +8613,7 @@ var ServerRuntime = class _ServerRuntime {
7828
8613
  if (signal?.aborted) {
7829
8614
  throw new AppError("CANCELLED", "The browser action was cancelled.");
7830
8615
  }
8616
+ await this.ensureBrowserProfileLease();
7831
8617
  return awaitWithAbort2(this.browser.closeSession(sessionId), signal);
7832
8618
  }
7833
8619
  async webSearch(query, options, signal) {
@@ -7845,7 +8631,7 @@ var ServerRuntime = class _ServerRuntime {
7845
8631
  mode: this.config.browser.mode,
7846
8632
  configured: managedBrowser || !browserDisabled && (usesExecutable ? Boolean(this.config.browser.executablePath) : Boolean(this.config.browser.wsEndpoint || this.config.browser.url)),
7847
8633
  connection: browserDisabled ? "disabled" : managedBrowser ? "managed" : usesExecutable ? "executable" : this.config.browser.wsEndpoint ? "websocket" : "devtools-http",
7848
- runtime: browserDisabled ? { connected: false, owned: false, trackedPages: 0, queuedOperations: 0, currentPageId: null } : this.browser.connectionStatus(),
8634
+ runtime: browserDisabled ? { connected: false, owned: false, trackedPages: 0, queuedOperations: 0, currentPageId: null, recoveryRequired: false } : this.browser.connectionStatus(),
7849
8635
  actionTimeoutMs: this.config.browser.actionTimeoutMs,
7850
8636
  connectTimeoutMs: this.config.browser.connectTimeoutMs,
7851
8637
  cdpTimeoutMs: this.config.browser.cdpTimeoutMs,
@@ -7875,7 +8661,7 @@ async function acquireBrowserProfileLease(profileDirectory) {
7875
8661
  const payload = JSON.stringify({ pid: process3.pid, token, createdAt: (/* @__PURE__ */ new Date()).toISOString() });
7876
8662
  for (let attempt = 0; attempt < 2; attempt += 1) {
7877
8663
  try {
7878
- const handle = await open(lockPath, "wx", 384);
8664
+ const handle = await open2(lockPath, "wx", 384);
7879
8665
  try {
7880
8666
  await handle.writeFile(payload, "utf8");
7881
8667
  await handle.sync();
@@ -8150,6 +8936,7 @@ var HTTP_SHUTDOWN_GRACE_MS = 5e3;
8150
8936
  var HTTP_HANDLER_SHUTDOWN_TIMEOUT_MS = 5e3;
8151
8937
  var HTTP_REQUEST_TIMEOUT_MS = 12e4;
8152
8938
  var HTTP_HEADERS_TIMEOUT_MS = 15e3;
8939
+ var HTTP_BODY_READ_TIMEOUT_MS = 3e4;
8153
8940
  async function main(args = process4.argv.slice(2)) {
8154
8941
  if (args[0] === "install") {
8155
8942
  const yes = args.includes("--yes") || args.includes("--no-interactive");
@@ -8335,6 +9122,10 @@ async function serveHttp(runtime, shutdown) {
8335
9122
  if (!response.headersSent) {
8336
9123
  const normalized = asAppError(error);
8337
9124
  const status = normalized.status >= 400 && normalized.status <= 599 ? normalized.status : 500;
9125
+ if (status === 408 || status === 413 || status === 499) {
9126
+ response.setHeader("connection", "close");
9127
+ response.once("finish", () => request.destroy());
9128
+ }
8338
9129
  response.writeHead(status, { "content-type": "application/json" });
8339
9130
  }
8340
9131
  if (!response.writableEnded) {
@@ -8422,7 +9213,7 @@ async function dispatchHttpRequest(request, response, nodeHandler, maxBodyBytes,
8422
9213
  await nodeHandler(request, response);
8423
9214
  return;
8424
9215
  }
8425
- const body = await readRequestBody(request, maxBodyBytes);
9216
+ const body = await readRequestBody(request, maxBodyBytes, HTTP_BODY_READ_TIMEOUT_MS);
8426
9217
  if (isSubscriptionRequestBody(body)) {
8427
9218
  promoteToStream?.();
8428
9219
  }
@@ -8437,37 +9228,56 @@ async function dispatchHttpRequest(request, response, nodeHandler, maxBodyBytes,
8437
9228
  });
8438
9229
  await nodeHandler(replay, response);
8439
9230
  }
8440
- async function readRequestBody(request, maxBodyBytes) {
9231
+ async function readRequestBody(request, maxBodyBytes, timeoutMs) {
8441
9232
  const chunks = [];
8442
9233
  let total = 0;
8443
- let tooLarge = false;
8444
- try {
9234
+ let timer;
9235
+ let timedOut = false;
9236
+ let discardChunks = false;
9237
+ let bodyPromise;
9238
+ const read = async () => {
8445
9239
  for await (const chunk of request) {
8446
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8447
- if (tooLarge) {
9240
+ if (discardChunks) {
8448
9241
  continue;
8449
9242
  }
9243
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8450
9244
  const nextTotal = total + buffer.byteLength;
8451
9245
  if (nextTotal > maxBodyBytes) {
8452
- tooLarge = true;
8453
- continue;
9246
+ throw new AppError("HTTP_BODY_TOO_LARGE", `HTTP request body exceeds the ${maxBodyBytes}-byte limit.`, { status: 413 });
8454
9247
  }
8455
9248
  total = nextTotal;
8456
9249
  chunks.push(buffer);
8457
9250
  }
9251
+ return Buffer.concat(chunks, total);
9252
+ };
9253
+ try {
9254
+ bodyPromise = read();
9255
+ const timeout = new Promise((_, reject) => {
9256
+ timer = setTimeout(() => {
9257
+ timedOut = true;
9258
+ reject(new AppError("HTTP_BODY_TIMEOUT", "The HTTP request body took too long to arrive.", { status: 408, retryable: true }));
9259
+ }, timeoutMs);
9260
+ });
9261
+ return await Promise.race([bodyPromise, timeout]);
8458
9262
  } catch (error) {
8459
- if (tooLarge) {
8460
- throw new AppError("HTTP_BODY_TOO_LARGE", `HTTP request body exceeds the ${maxBodyBytes}-byte limit.`, { status: 413, cause: error });
9263
+ discardChunks = true;
9264
+ bodyPromise?.catch(() => void 0);
9265
+ if (timedOut) {
9266
+ request.pause();
9267
+ throw error;
9268
+ }
9269
+ if (error instanceof AppError && (error.code === "HTTP_BODY_TOO_LARGE" || error.code === "HTTP_BODY_TIMEOUT")) {
9270
+ throw error;
8461
9271
  }
8462
9272
  if (request.aborted) {
8463
9273
  throw new AppError("HTTP_REQUEST_ABORTED", "The HTTP client disconnected before the request completed.", { status: 499, retryable: true, cause: error });
8464
9274
  }
8465
9275
  throw error;
9276
+ } finally {
9277
+ if (timer) {
9278
+ clearTimeout(timer);
9279
+ }
8466
9280
  }
8467
- if (tooLarge) {
8468
- throw new AppError("HTTP_BODY_TOO_LARGE", `HTTP request body exceeds the ${maxBodyBytes}-byte limit.`, { status: 413 });
8469
- }
8470
- return Buffer.concat(chunks, total);
8471
9281
  }
8472
9282
  function isPotentialHttpStream(request) {
8473
9283
  return request.method === "GET";
@@ -8495,8 +9305,9 @@ function authorized(request, token) {
8495
9305
  return false;
8496
9306
  }
8497
9307
  const presented = Buffer.from(header.slice("Bearer ".length));
8498
- const expected = Buffer.from(token);
8499
- return presented.length === expected.length && timingSafeEqual(presented, expected);
9308
+ const expected = createHash("sha256").update(token).digest();
9309
+ const presentedDigest = createHash("sha256").update(presented).digest();
9310
+ return presentedDigest.length === expected.length && timingSafeEqual(presentedDigest, expected);
8500
9311
  }
8501
9312
  if (isMainModule()) {
8502
9313
  void main().catch((error) => {