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.
- package/README.md +1 -1
- package/dist/smooth-operator.mjs +1083 -272
- package/dist/smooth-operator.mjs.map +2 -2
- package/docs/harnesses.md +1 -1
- package/docs/mcp-server.md +32 -0
- package/package.json +1 -1
package/dist/smooth-operator.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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.
|
|
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.",
|
|
2608
|
-
registerAction(server, runtime, "browser_go_back", "Go back", "Browser-use-compatible alias for browser_back.",
|
|
2609
|
-
registerAction(server, runtime, "browser_forward", "Go forward", "Navigate the current tab one history entry forward.",
|
|
2610
|
-
registerAction(server, runtime, "browser_reload", "Reload the page", "Reload the current tab and re-apply navigation policy to the final URL.",
|
|
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
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
4223
|
-
|
|
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
|
|
4613
|
+
const includeLinks = action.includeLinks === true;
|
|
4614
|
+
const extracted = resolvedSelector ? await frame.$eval(resolvedSelector, (element, options) => {
|
|
4298
4615
|
const fullText = element.textContent ?? "";
|
|
4299
|
-
|
|
4300
|
-
|
|
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
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
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
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
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
|
|
4739
|
+
const staged = await this.stageUploadFile(requireField(action.filePath, "filePath"), signal);
|
|
4423
4740
|
throwIfAborted(signal);
|
|
4424
|
-
|
|
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(
|
|
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(
|
|
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
|
|
4478
|
-
|
|
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
|
|
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
|
|
4699
|
-
const action =
|
|
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 !==
|
|
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:
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
5898
|
-
Math.round(
|
|
5899
|
-
Math.round(
|
|
5900
|
-
Math.round(
|
|
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
|
-
|
|
6101
|
-
|
|
6102
|
-
|
|
6103
|
-
|
|
6104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6127
|
-
|
|
6128
|
-
|
|
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
|
-
() =>
|
|
6778
|
+
(result) => result,
|
|
6204
6779
|
(error) => {
|
|
6205
6780
|
removeDialogListener?.();
|
|
6206
6781
|
if (dialogObserved) {
|
|
6207
|
-
return
|
|
6782
|
+
return { navigated: false, urlChanged: false };
|
|
6208
6783
|
}
|
|
6209
6784
|
throw error;
|
|
6210
6785
|
}
|
|
6211
6786
|
);
|
|
6212
|
-
const openedDialog = await Promise.race([click, dialogOpened
|
|
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(
|
|
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
|
|
7142
|
+
async stageUploadFile(rawPath, signal) {
|
|
6573
7143
|
const candidate = this.policy.assertFilePath(rawPath, { mustExist: true });
|
|
6574
|
-
await
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
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
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
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.
|
|
6677
|
-
|
|
6678
|
-
|
|
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 (
|
|
7351
|
+
if (abortRequested && operationPromise) {
|
|
6703
7352
|
deferRelease = true;
|
|
6704
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
8444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8460
|
-
|
|
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 =
|
|
8499
|
-
|
|
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) => {
|