surf-cli 2.18.0 → 2.19.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/native/doctor.cjs CHANGED
@@ -405,6 +405,14 @@ function buildRecommendations(report) {
405
405
 
406
406
  function remoteRecommendations(endpoint, code) {
407
407
  const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
408
+ const tlsCodes = new Set([
409
+ "ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "SELF_SIGNED_CERT_IN_CHAIN",
410
+ "DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
411
+ "CERT_HAS_EXPIRED",
412
+ ]);
413
+ if (endpoint.tls?.enabled && (code === "ETIMEDOUT" || tlsCodes.has(code))) {
414
+ return [...base, "Check the reverse-proxy certificate chain and expected server name (or --remote-tls-server-name); a custom CA replaces system roots."];
415
+ }
408
416
  if (code === "ENOTFOUND") return [...base, "Check the Tailnet DNS name, then run `tailscale status` and `tailscale ping <host>`."].map((item) => item.replace("<host>", endpoint.host));
409
417
  if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
410
418
  if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
@@ -445,9 +453,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
445
453
  socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
446
454
  socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
447
455
  })))(endpoint, options.connectTimeoutMs);
448
- const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}`, endpoint: endpoint.display }, {
456
+ const tlsMarker = endpoint.tls?.enabled ? " (TLS)" : "";
457
+ const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}${tlsMarker}`, endpoint: endpoint.display }, {
449
458
  id: "remote-connect", status: connection.ok ? "pass" : "fail",
450
- message: connection.ok ? `Connected to remote endpoint ${endpoint.display}` : `Could not connect to remote endpoint ${endpoint.display}: ${connection.message}`,
459
+ message: connection.ok ? `Connected to remote endpoint ${endpoint.display}${tlsMarker}` : `Could not connect to remote endpoint ${endpoint.display}${tlsMarker}: ${connection.message}`,
451
460
  code: connection.code,
452
461
  }, {
453
462
  id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
@@ -1,7 +1,12 @@
1
1
  const net = require("net");
2
+ const fs = require("fs");
3
+ const tls = require("tls");
2
4
  const { DEFAULT_SOCKET_PATH } = require("./socket-path.cjs");
3
5
  const { authenticateClient } = require("./remote-transport.cjs");
4
6
 
7
+ const TLS_HANDSHAKE_TIMEOUT_MS = 5000;
8
+ const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
9
+
5
10
  function parseRemoteEndpoint(value) {
6
11
  if (typeof value !== "string" || !value) throw new Error("--remote requires host:port");
7
12
  let host;
@@ -16,7 +21,7 @@ function parseRemoteEndpoint(value) {
16
21
  if (!match) throw new Error("remote endpoint must be host:port (IPv6 must be bracketed)");
17
22
  [, host, portText] = match;
18
23
  if (host.includes("/") || host.includes("@") || host.includes(":") || host === "*" || host.includes("*")) throw new Error("remote endpoint host is invalid");
19
- if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 && !/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(host))) {
24
+ if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 && !HOSTNAME_PATTERN.test(host))) {
20
25
  throw new Error("remote endpoint host is invalid");
21
26
  }
22
27
  host = host.toLowerCase();
@@ -30,45 +35,107 @@ function parseRemoteEndpoint(value) {
30
35
  return { kind: "remote", host, port, display, key: `tcp:${display}`, connectionOptions: { host, port } };
31
36
  }
32
37
 
38
+ function extractRemoteOptions(args) {
39
+ const definitions = {
40
+ "--remote": { name: "remote", missing: "--remote requires host:port" },
41
+ "--remote-credential": { name: "credential", missing: "--remote-credential requires a file path" },
42
+ "--remote-tls": { name: "tls", boolean: true },
43
+ "--remote-tls-ca": { name: "tlsCa", missing: "--remote-tls-ca requires a file path" },
44
+ "--remote-tls-server-name": { name: "tlsServerName", missing: "--remote-tls-server-name requires a DNS hostname" },
45
+ };
46
+ const values = {};
47
+ const strippedArgs = [];
48
+ for (let index = 0; index < args.length; index++) {
49
+ const option = definitions[args[index]];
50
+ if (!option) {
51
+ strippedArgs.push(args[index]);
52
+ continue;
53
+ }
54
+ if (Object.hasOwn(values, option.name)) throw new Error(`${args[index]} may only be specified once`);
55
+ if (option.boolean) {
56
+ values[option.name] = true;
57
+ continue;
58
+ }
59
+ const value = args[index + 1];
60
+ if (!value || value.startsWith("--")) throw new Error(option.missing);
61
+ values[option.name] = value;
62
+ index++;
63
+ }
64
+ return { values, strippedArgs };
65
+ }
66
+
67
+ function validateServerName(value, source) {
68
+ if (!value || net.isIP(value) !== 0 || !HOSTNAME_PATTERN.test(value)) {
69
+ throw new Error(`${source} must be a valid DNS hostname`);
70
+ }
71
+ return value.toLowerCase();
72
+ }
73
+
74
+ function loadTlsCa(caPath, source) {
75
+ let ca;
76
+ try {
77
+ ca = fs.readFileSync(caPath);
78
+ } catch (error) {
79
+ throw new Error(`${source} could not read CA file ${caPath}: ${error.message}`);
80
+ }
81
+ if (ca.length === 0 || !ca.includes(Buffer.from("-----BEGIN CERTIFICATE-----"))) {
82
+ throw new Error(`${source} CA file ${caPath} must contain a PEM certificate`);
83
+ }
84
+ return ca;
85
+ }
86
+
33
87
  function selectEndpoint(args, env) {
34
88
  const selectedEnv = env === undefined ? process.env : env;
35
- const remoteIndexes = [];
36
- const credentialIndexes = [];
37
- for (let i = 0; i < args.length; i++) {
38
- if (args[i] === "--remote") remoteIndexes.push(i);
39
- if (args[i] === "--remote-credential") credentialIndexes.push(i);
40
- }
41
- if (remoteIndexes.length > 1) throw new Error("--remote may only be specified once");
42
- if (credentialIndexes.length > 1) throw new Error("--remote-credential may only be specified once");
43
- let cliRemote;
44
- let cliCredential;
45
- const strippedArgs = [...args];
46
- if (remoteIndexes.length) {
47
- const index = remoteIndexes[0];
48
- cliRemote = args[index + 1];
49
- if (!cliRemote || cliRemote.startsWith("--")) throw new Error("--remote requires host:port");
50
- strippedArgs.splice(index, 2);
89
+ const { values, strippedArgs } = extractRemoteOptions(args);
90
+ const envTls = selectedEnv.SURF_REMOTE_TLS;
91
+ if (envTls && envTls !== "1") {
92
+ throw new Error('SURF_REMOTE_TLS must be "1" to enable TLS; unset it to disable');
51
93
  }
52
- if (credentialIndexes.length) {
53
- const index = credentialIndexes[0];
54
- cliCredential = args[index + 1];
55
- if (!cliCredential || cliCredential.startsWith("--")) throw new Error("--remote-credential requires a file path");
56
- const adjustedIndex = index - (remoteIndexes.length && index > remoteIndexes[0] ? 2 : 0);
57
- strippedArgs.splice(adjustedIndex, 2);
94
+ const remoteValue = values.remote || selectedEnv.SURF_REMOTE;
95
+ const credentialPath = values.credential || selectedEnv.SURF_REMOTE_CREDENTIAL;
96
+ const tlsEnabled = values.tls === true || envTls === "1";
97
+ const caPath = values.tlsCa || selectedEnv.SURF_REMOTE_TLS_CA;
98
+ const serverNameValue = values.tlsServerName || selectedEnv.SURF_REMOTE_TLS_SERVER_NAME;
99
+ if (!remoteValue) {
100
+ if (values.credential) throw new Error("--remote-credential requires a remote endpoint");
101
+ if (values.tls) throw new Error("--remote-tls requires a remote endpoint");
102
+ if (selectedEnv.SURF_REMOTE_TLS === "1") throw new Error("SURF_REMOTE_TLS requires a remote endpoint");
103
+ if (values.tlsCa) throw new Error("--remote-tls-ca requires a remote endpoint");
104
+ if (selectedEnv.SURF_REMOTE_TLS_CA) throw new Error("SURF_REMOTE_TLS_CA requires a remote endpoint");
105
+ if (values.tlsServerName) throw new Error("--remote-tls-server-name requires a remote endpoint");
106
+ if (selectedEnv.SURF_REMOTE_TLS_SERVER_NAME) throw new Error("SURF_REMOTE_TLS_SERVER_NAME requires a remote endpoint");
107
+ const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
108
+ return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
58
109
  }
59
- const remoteValue = cliRemote || selectedEnv.SURF_REMOTE;
60
- if (remoteValue) {
61
- const credentialPath = cliCredential || selectedEnv.SURF_REMOTE_CREDENTIAL;
62
- if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
63
- return { args: strippedArgs, endpoint: { ...parseRemoteEndpoint(remoteValue), credentialPath } };
110
+ if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
111
+ if (caPath && !tlsEnabled) throw new Error(`${values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA"} requires TLS to be enabled`);
112
+ if (serverNameValue && !tlsEnabled) throw new Error(`${values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME"} requires TLS to be enabled`);
113
+ const endpoint = { ...parseRemoteEndpoint(remoteValue), credentialPath };
114
+ if (tlsEnabled) {
115
+ const tlsOptions = { enabled: true };
116
+ if (caPath) {
117
+ tlsOptions.ca = loadTlsCa(caPath, values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA");
118
+ tlsOptions.caPath = caPath;
119
+ }
120
+ const serverName = serverNameValue
121
+ ? validateServerName(serverNameValue, values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME")
122
+ : net.isIP(endpoint.host) === 0 ? endpoint.host : undefined;
123
+ if (serverName) tlsOptions.serverName = serverName;
124
+ endpoint.tls = tlsOptions;
64
125
  }
65
- if (cliCredential) throw new Error("--remote-credential requires a remote endpoint");
66
- const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
67
- return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
126
+ return { args: strippedArgs, endpoint };
68
127
  }
69
128
 
70
129
  function createRemoteSocket(endpoint) {
71
- const rawSocket = net.createConnection(endpoint.connectionOptions, () => {});
130
+ const usingTls = endpoint.tls?.enabled === true;
131
+ const rawSocket = usingTls
132
+ ? tls.connect({
133
+ ...endpoint.connectionOptions,
134
+ rejectUnauthorized: true,
135
+ ...(endpoint.tls.ca ? { ca: endpoint.tls.ca } : {}),
136
+ ...(endpoint.tls.serverName ? { servername: endpoint.tls.serverName } : {}),
137
+ })
138
+ : net.createConnection(endpoint.connectionOptions, () => {});
72
139
  let ready = false;
73
140
  let connected = false;
74
141
  let destroyed = false;
@@ -106,7 +173,25 @@ function createRemoteSocket(endpoint) {
106
173
  get authenticated() { return ready; },
107
174
  get connected() { return connected; },
108
175
  };
109
- rawSocket.once("connect", () => { connected = true; });
176
+ const transportReadyEvent = usingTls ? "secureConnect" : "connect";
177
+ rawSocket.once(transportReadyEvent, () => { connected = true; });
178
+ let handshakeTimer;
179
+ if (usingTls) {
180
+ const timeoutMs = endpoint.tls.handshakeTimeoutMs ?? TLS_HANDSHAKE_TIMEOUT_MS;
181
+ const clearHandshakeTimer = () => {
182
+ if (handshakeTimer) clearTimeout(handshakeTimer);
183
+ handshakeTimer = undefined;
184
+ };
185
+ handshakeTimer = setTimeout(() => {
186
+ if (connected || rawSocket.destroyed) return;
187
+ const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
188
+ error.code = "ETIMEDOUT";
189
+ rawSocket.destroy(error);
190
+ }, timeoutMs);
191
+ rawSocket.once("secureConnect", clearHandshakeTimer);
192
+ rawSocket.once("error", clearHandshakeTimer);
193
+ rawSocket.once("close", clearHandshakeTimer);
194
+ }
110
195
  rawSocket.on("error", (error) => {
111
196
  if (ready || destroyed) return;
112
197
  ready = true;
@@ -133,7 +218,7 @@ function connectEndpoint(endpoint, onConnect) {
133
218
  return net.createConnection(endpoint.connectionOptions, onConnect || (() => {}));
134
219
  }
135
220
  const { rawSocket, proxy, flush } = createRemoteSocket(endpoint);
136
- rawSocket.once("connect", () => {
221
+ rawSocket.once(endpoint.tls?.enabled ? "secureConnect" : "connect", () => {
137
222
  authenticateClient(rawSocket, endpoint.credentialPath)
138
223
  .then(() => {
139
224
  flush();
@@ -168,7 +253,7 @@ function connectEndpoint(endpoint, onConnect) {
168
253
  function formatEndpointError(error, endpoint, formatSocketError) {
169
254
  if (endpoint.kind === "local") return formatSocketError(error);
170
255
  const message = error?.message || String(error);
171
- return `Remote endpoint connection failed (${endpoint.display}): ${message}`;
256
+ return `Remote endpoint connection failed (${endpoint.display}${endpoint.tls?.enabled ? ", TLS" : ""}): ${message}`;
172
257
  }
173
258
 
174
- module.exports = { parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
259
+ module.exports = { TLS_HANDSHAKE_TIMEOUT_MS, parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
@@ -0,0 +1,362 @@
1
+ /** Retries use fresh tabs, so caller scripts must be read-only or idempotent. */
2
+
3
+ const { applyOptionsPrelude } = require("./script-options.cjs");
4
+
5
+ const DEFAULT_RETRY_COUNT = 1;
6
+ const DEFAULT_RETRY_DELAY_MS = 500;
7
+ const MAX_RETRY_COUNT = 5;
8
+ const ROW_KEY_CANDIDATES = ["rows", "items", "results", "entries", "records", "data"];
9
+
10
+ const TRANSIENT_TAB_ERROR_MARKERS = [
11
+ "navigated or closed",
12
+ "Detached while handling command",
13
+ "Cannot find default execution context",
14
+ "Execution context was destroyed",
15
+ "Receiving end does not exist",
16
+ "Content script not loaded",
17
+ "no longer exists",
18
+ "Target closed",
19
+ ];
20
+
21
+ const RETRYABLE_ERROR_CODES = new Set(["empty_result", "page_timeout", "tab_gone", "target_gone"]);
22
+
23
+ const FATAL_READINESS_CODES = new Set(["page_login", "page_challenge", "page_not_found", "page_error"]);
24
+
25
+ class ExtractError extends Error {
26
+ constructor(code, message, details = {}) {
27
+ super(message);
28
+ this.name = "ExtractError";
29
+ this.code = code;
30
+ this.details = details;
31
+ }
32
+ }
33
+
34
+ function errorMessageOf(error) {
35
+ if (error instanceof Error) return error.message;
36
+ if (typeof error === "string") return error;
37
+ if (error && typeof error === "object") {
38
+ const text = error.content?.[0]?.text;
39
+ if (typeof text === "string") return text;
40
+ if (typeof error.message === "string") return error.message;
41
+ return JSON.stringify(error);
42
+ }
43
+ return String(error);
44
+ }
45
+
46
+ function errorCodeOf(error) {
47
+ if (error && typeof error === "object" && typeof error.code === "string") return error.code;
48
+ return null;
49
+ }
50
+
51
+ function isTransientTabError(error) {
52
+ const message = errorMessageOf(error);
53
+ return TRANSIENT_TAB_ERROR_MARKERS.some((marker) => message.includes(marker));
54
+ }
55
+
56
+ /**
57
+ * Whether a failed attempt is worth a fresh tab. Login bounces, challenges
58
+ * and not-found pages are not: the next tab lands on the same page.
59
+ */
60
+ function isRetryableExtractionError(error) {
61
+ const code = errorCodeOf(error);
62
+ if (code && FATAL_READINESS_CODES.has(code)) return false;
63
+ if (code && RETRYABLE_ERROR_CODES.has(code)) return true;
64
+ return isTransientTabError(error);
65
+ }
66
+
67
+ function responseText(response) {
68
+ const text = response?.result?.content?.[0]?.text;
69
+ return typeof text === "string" ? text : null;
70
+ }
71
+
72
+ function responseError(response, stage) {
73
+ if (!response || !response.error) return null;
74
+ const err = response.error;
75
+ const code = errorCodeOf(err) || "tool_error";
76
+ return new ExtractError(code, errorMessageOf(err), { stage, ...(err.details || {}) });
77
+ }
78
+
79
+ function parseExtractionOutput(text) {
80
+ if (text === null || text === undefined || text.trim() === "" || text.trim() === "undefined") {
81
+ throw new ExtractError(
82
+ "no_output",
83
+ "The extraction script returned nothing. End it with `return { rows: [...] }` or `return [...]`.",
84
+ );
85
+ }
86
+ try {
87
+ return JSON.parse(text);
88
+ } catch (error) {
89
+ throw new ExtractError("invalid_output", `The extraction script did not return JSON: ${error.message}`, {
90
+ preview: text.slice(0, 200),
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Pick the row array out of the script result: the result itself when it
97
+ * is an array, `--rows <key>` when given, else the first conventional key
98
+ * holding an array. Returns null when the result has no row concept.
99
+ */
100
+ function selectRows(data, rowsKey) {
101
+ if (rowsKey) {
102
+ const rows = data && typeof data === "object" && !Array.isArray(data) ? data[rowsKey] : undefined;
103
+ if (!Array.isArray(rows)) {
104
+ throw new ExtractError("rows_key_missing", `The script result has no array at "${rowsKey}"`, {
105
+ keys: data && typeof data === "object" ? Object.keys(data) : [],
106
+ });
107
+ }
108
+ return rows;
109
+ }
110
+ if (Array.isArray(data)) return data;
111
+ if (data && typeof data === "object") {
112
+ for (const key of ROW_KEY_CANDIDATES) {
113
+ if (Array.isArray(data[key])) return data[key];
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Zero rows is a failure unless the caller opts in. A logged-out render,
121
+ * a selector miss or a half-loaded page all look like "no results"; only
122
+ * the caller knows whether an empty result is plausible.
123
+ */
124
+ function enforceRowsInvariant(rows, { allowEmpty = false, readiness } = {}) {
125
+ if (!Array.isArray(rows) || rows.length > 0 || allowEmpty) return;
126
+ if (readiness?.state === "empty") return;
127
+ throw new ExtractError(
128
+ "empty_result",
129
+ "The extraction returned zero rows (the page may be logged out, blocked, or the selectors missed). Pass --allow-empty to accept an empty result, or --empty-text to recognise the page's own no-results message.",
130
+ { rows: 0 },
131
+ );
132
+ }
133
+
134
+ function cellText(value) {
135
+ let text;
136
+ if (value === null || value === undefined) text = "";
137
+ else if (typeof value === "string") text = value;
138
+ else text = JSON.stringify(value);
139
+ return text.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
140
+ }
141
+
142
+ /** Markdown for humans and LLMs: metadata bullets, then a table of rows. */
143
+ function renderExtractionMarkdown(data, rows, { title = "Extraction" } = {}) {
144
+ const lines = [`# ${title}`, ""];
145
+ if (data && typeof data === "object" && !Array.isArray(data)) {
146
+ for (const [key, value] of Object.entries(data)) {
147
+ if (Array.isArray(value) || (value && typeof value === "object")) continue;
148
+ lines.push(`- ${key}: ${cellText(value)}`);
149
+ }
150
+ if (lines.length > 2) lines.push("");
151
+ }
152
+ if (!Array.isArray(rows)) {
153
+ lines.push("```json", JSON.stringify(data, null, 2), "```");
154
+ return lines.join("\n");
155
+ }
156
+ lines.push(`${rows.length} row${rows.length === 1 ? "" : "s"}`, "");
157
+ if (rows.length === 0) return lines.join("\n").trimEnd();
158
+ if (!rows.every((row) => row !== null && typeof row === "object" && !Array.isArray(row))) {
159
+ for (const row of rows) lines.push(`- ${cellText(row)}`);
160
+ return lines.join("\n");
161
+ }
162
+ const columns = [];
163
+ for (const row of rows) {
164
+ for (const key of Object.keys(row)) {
165
+ if (!columns.includes(key)) columns.push(key);
166
+ }
167
+ }
168
+ lines.push(`| ${columns.join(" | ")} |`);
169
+ lines.push(`| ${columns.map(() => "---").join(" | ")} |`);
170
+ for (const row of rows) {
171
+ lines.push(`| ${columns.map((column) => cellText(row[column])).join(" | ")} |`);
172
+ }
173
+ return lines.join("\n");
174
+ }
175
+
176
+ function normalizeRetry(retry) {
177
+ const count = Number.isInteger(retry?.count) ? Math.max(0, Math.min(retry.count, MAX_RETRY_COUNT)) : DEFAULT_RETRY_COUNT;
178
+ const delayMs = Number.isFinite(retry?.delayMs) && retry.delayMs >= 0 ? retry.delayMs : DEFAULT_RETRY_DELAY_MS;
179
+ return { count, delayMs };
180
+ }
181
+
182
+ function readinessArgs(ready = {}) {
183
+ const args = {};
184
+ if (ready.selector) args.selector = ready.selector;
185
+ if (ready.text) args.text = ready.text;
186
+ if (ready.urlPrefix) args.urlPrefix = ready.urlPrefix;
187
+ if (ready.emptyText) args.emptyText = ready.emptyText;
188
+ if (ready.timeout !== undefined) args.timeout = ready.timeout;
189
+ if (ready.interval !== undefined) args.interval = ready.interval;
190
+ return args;
191
+ }
192
+
193
+ /** Tab id from the stable structured field on a tab.new host response. */
194
+ function tabIdFromResponse(response) {
195
+ const failure = responseError(response, "tab.new");
196
+ if (failure) throw failure;
197
+ const tabId = response?.result?.tabId;
198
+ if (Number.isInteger(tabId) && tabId > 0) return tabId;
199
+ throw new ExtractError("no_tab", "tab.new did not return a structured tab id");
200
+ }
201
+
202
+ function parseToolJson(response, stage) {
203
+ const failure = responseError(response, stage);
204
+ if (failure) throw failure;
205
+ const text = responseText(response);
206
+ if (text === null) return null;
207
+ try {
208
+ return JSON.parse(text);
209
+ } catch {
210
+ return text;
211
+ }
212
+ }
213
+
214
+ /** Drop transport-only fields from the readiness metadata returned to callers. */
215
+ function cleanReadiness(readiness) {
216
+ if (!readiness || typeof readiness !== "object") return readiness;
217
+ const { id, _resolvedTabId, _resolvedWindowId, _hint, ...publicReadiness } = readiness;
218
+ return publicReadiness;
219
+ }
220
+
221
+ async function runAttemptOnTab(executeTool, tabId, settings) {
222
+ const readiness = cleanReadiness(
223
+ parseToolJson(await executeTool("wait.ready", readinessArgs(settings.ready), tabId), "wait.ready"),
224
+ );
225
+ const code = applyOptionsPrelude(settings.code, settings.options);
226
+ const jsResponse = await executeTool("js", { code }, tabId);
227
+ const failure = responseError(jsResponse, "js");
228
+ if (failure) throw failure;
229
+ const data = parseExtractionOutput(responseText(jsResponse));
230
+ const rows = selectRows(data, settings.rowsKey);
231
+ enforceRowsInvariant(rows, { allowEmpty: settings.allowEmpty, readiness });
232
+ return { data, rows, readiness };
233
+ }
234
+
235
+ /**
236
+ * @param {object} settings
237
+ * @param {(tool: string, args: object, tabId?: number) => Promise<object>} settings.executeTool
238
+ * Sends one tool request. `tabId` overrides the target for owned tabs; when
239
+ * it is undefined the caller's default target (session/tab/window) applies.
240
+ * @param {string} settings.code Page-side script; must `return` JSON.
241
+ * @param {string} [settings.url] Page to open. Required unless `target` is set.
242
+ * @param {object} [settings.options] Exposed to the script as SURF_OPTIONS.
243
+ * @param {object} [settings.ready] wait.ready expectations (selector, text, urlPrefix, emptyText, timeout, interval).
244
+ * @param {{count?: number, delayMs?: number}} [settings.retry]
245
+ * @param {boolean} [settings.keepTab] Leave the owned tab open on success.
246
+ * @param {boolean} [settings.allowEmpty]
247
+ * @param {string} [settings.rowsKey]
248
+ * @param {boolean} [settings.target] Use the caller's target instead of an owned tab.
249
+ * @param {(error: unknown) => boolean} [settings.isRetryable]
250
+ * @param {(ms: number) => Promise<void>} [settings.sleep]
251
+ * @param {(event: object) => void} [settings.onEvent]
252
+ */
253
+ async function runExtraction(settings) {
254
+ const {
255
+ executeTool,
256
+ code,
257
+ url,
258
+ target = false,
259
+ keepTab = false,
260
+ isRetryable = isRetryableExtractionError,
261
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
262
+ onEvent = () => {},
263
+ } = settings;
264
+ if (typeof executeTool !== "function") throw new Error("runExtraction requires executeTool");
265
+ if (typeof code !== "string" || code.trim() === "") throw new ExtractError("no_script", "An extraction script is required (--file or --code)");
266
+ if (!target && !url) throw new ExtractError("no_url", "A URL is required unless --tab-id or --session names the page to read");
267
+
268
+ if (target) {
269
+ // Caller-supplied target: navigate once if asked, never close, never retry.
270
+ if (url) {
271
+ const navigation = await executeTool("navigate", { url });
272
+ const failure = responseError(navigation, "navigate");
273
+ if (failure) throw failure;
274
+ }
275
+ onEvent({ type: "attempt", attempt: 1, of: 1, mode: "target" });
276
+ const attempt = await runAttemptOnTab(executeTool, undefined, settings);
277
+ return { ...attempt, rowCount: Array.isArray(attempt.rows) ? attempt.rows.length : null, attempts: 1, mode: "target", url: url ?? null };
278
+ }
279
+
280
+ const retry = normalizeRetry(settings.retry);
281
+ const attempts = retry.count + 1;
282
+ let lastError = null;
283
+ let attemptsMade = 0;
284
+
285
+ for (let attempt = 1; attempt <= attempts; attempt++) {
286
+ attemptsMade = attempt;
287
+ if (attempt > 1) await sleep(retry.delayMs);
288
+ onEvent({ type: "attempt", attempt, of: attempts, mode: "owned-tab" });
289
+ let tabId = null;
290
+ let result;
291
+ try {
292
+ tabId = tabIdFromResponse(await executeTool("tab.new", { url }));
293
+ result = await runAttemptOnTab(executeTool, tabId, settings);
294
+ } catch (error) {
295
+ lastError = error;
296
+ let cleanupError = null;
297
+ if (tabId) {
298
+ try {
299
+ const closed = await executeTool("tab.close", { id: tabId }, tabId);
300
+ const closeFailure = responseError(closed, "tab.close");
301
+ if (closeFailure) throw closeFailure;
302
+ } catch (closeError) {
303
+ cleanupError = closeError;
304
+ onEvent({ type: "close-failed", attempt, tabId, error: errorMessageOf(closeError) });
305
+ }
306
+ }
307
+ if (cleanupError) {
308
+ throw new ExtractError("cleanup_failed", `Extraction failed and the owned tab could not be closed: ${errorMessageOf(cleanupError)}`, {
309
+ stage: "tab.close",
310
+ tabId,
311
+ attempts: attempt,
312
+ extractionError: { code: errorCodeOf(error), message: errorMessageOf(error) },
313
+ });
314
+ }
315
+ const retryable = attempt < attempts && isRetryable(error);
316
+ onEvent({ type: "attempt-failed", attempt, of: attempts, error: errorMessageOf(error), code: errorCodeOf(error), retryable });
317
+ if (!retryable) break;
318
+ continue;
319
+ }
320
+
321
+ if (!keepTab) {
322
+ try {
323
+ const closed = await executeTool("tab.close", { id: tabId }, tabId);
324
+ const closeFailure = responseError(closed, "tab.close");
325
+ if (closeFailure) throw closeFailure;
326
+ } catch (error) {
327
+ throw new ExtractError("cleanup_failed", `Extraction succeeded but the owned tab could not be closed: ${errorMessageOf(error)}`, {
328
+ stage: "tab.close",
329
+ tabId,
330
+ attempts: attempt,
331
+ extractionSucceeded: true,
332
+ rowCount: Array.isArray(result.rows) ? result.rows.length : null,
333
+ });
334
+ }
335
+ }
336
+ return {
337
+ ...result,
338
+ rowCount: Array.isArray(result.rows) ? result.rows.length : null,
339
+ attempts: attempt,
340
+ mode: "owned-tab",
341
+ url,
342
+ tabId: keepTab ? tabId : null,
343
+ };
344
+ }
345
+ if (lastError instanceof ExtractError) {
346
+ lastError.details = { ...lastError.details, attempts: attemptsMade };
347
+ throw lastError;
348
+ }
349
+ throw new ExtractError(errorCodeOf(lastError) || "extraction_failed", errorMessageOf(lastError), { attempts: attemptsMade });
350
+ }
351
+
352
+ module.exports = {
353
+ ExtractError,
354
+ enforceRowsInvariant,
355
+ isRetryableExtractionError,
356
+ isTransientTabError,
357
+ parseExtractionOutput,
358
+ renderExtractionMarkdown,
359
+ runExtraction,
360
+ selectRows,
361
+ tabIdFromResponse,
362
+ };
@@ -462,13 +462,37 @@ function formatToolContent(result, log = () => {}, options = {}) {
462
462
  }
463
463
 
464
464
  // Strip internal fields before JSON output
465
- const { _resolvedTabId, _hint, ...cleanResult } = result;
465
+ const { _resolvedTabId, _resolvedWindowId, _hint, ...cleanResult } = result;
466
466
  if (_hint) {
467
467
  return text(JSON.stringify(cleanResult) + `\n[hint] ${_hint}`);
468
468
  }
469
469
  return text(JSON.stringify(cleanResult));
470
470
  }
471
471
 
472
+ /**
473
+ * Readiness expectations accept both CLI flag spelling (--url-prefix) and
474
+ * socket API spelling (urlPrefix). Empty values are dropped.
475
+ */
476
+ function readinessExpectations(a) {
477
+ const pick = (...keys) => {
478
+ for (const key of keys) {
479
+ const value = a[key];
480
+ if (typeof value === "string" && value.trim() !== "") return value;
481
+ }
482
+ return undefined;
483
+ };
484
+ const expect = {
485
+ selector: pick("selector"),
486
+ text: pick("text"),
487
+ urlPrefix: pick("urlPrefix", "url-prefix"),
488
+ emptyText: pick("emptyText", "empty-text"),
489
+ };
490
+ for (const key of Object.keys(expect)) {
491
+ if (expect[key] === undefined) delete expect[key];
492
+ }
493
+ return expect;
494
+ }
495
+
472
496
  /**
473
497
  * Map computer action to extension message
474
498
  */
@@ -871,10 +895,23 @@ function mapToolToMessage(tool, args, tabId) {
871
895
  return { type: "WAIT_FOR_URL", pattern: a.pattern || a.url, timeout: a.timeout, ...baseMsg };
872
896
  case "wait.dom":
873
897
  return { type: "WAIT_FOR_DOM_STABLE", stable: a.stable || 100, timeout: a.timeout || 5000, ...baseMsg };
898
+ case "wait.ready":
899
+ return {
900
+ type: "WAIT_FOR_READY",
901
+ expect: readinessExpectations(a),
902
+ timeout: a.timeout,
903
+ interval: a.interval,
904
+ accept: a.accept,
905
+ ...baseMsg,
906
+ };
907
+ case "page.readiness":
908
+ return { type: "PAGE_READINESS", expect: readinessExpectations(a), ...baseMsg };
874
909
  case "wait.load":
875
910
  return { type: "WAIT_FOR_LOAD", timeout: a.timeout || 30000, ...baseMsg };
876
911
  case "frame.list":
877
912
  return { type: "GET_FRAMES", ...baseMsg };
913
+ case "frame.diagnose":
914
+ return { type: "FRAME_DIAGNOSE", ...baseMsg };
878
915
  case "frame.switch":
879
916
  return {
880
917
  type: "FRAME_SWITCH",
@@ -1280,4 +1317,4 @@ function mapToolToMessage(tool, args, tabId) {
1280
1317
  }
1281
1318
  }
1282
1319
 
1283
- module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage };
1320
+ module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, readinessExpectations };