surf-cli 2.17.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/README.md +135 -9
- package/agents/gpt-pro.md +2 -2
- package/native/browser-session-store.cjs +30 -2
- package/native/chatgpt-client-selection.cjs +79 -35
- package/native/chatgpt-client-ui.cjs +313 -198
- package/native/chatgpt-client.cjs +7 -2
- package/native/cli.cjs +443 -13
- package/native/doctor.cjs +11 -2
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/file-transfer.cjs +5 -1
- package/native/host-helpers.cjs +40 -3
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +451 -26
- package/native/mcp-server.cjs +26 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/script-options.cjs +33 -0
- package/native/socket-permissions.cjs +114 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +8 -4
- package/native/video-recorder.cjs +444 -0
- package/native/workflow-definition.cjs +5 -0
- package/package.json +6 -6
- package/scripts/install-native-host.cjs +53 -6
- package/skills/surf/SKILL.md +25 -3
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
|
|
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",
|
package/native/endpoint.cjs
CHANGED
|
@@ -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 &&
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/native/file-transfer.cjs
CHANGED
|
@@ -279,6 +279,9 @@ function validateLocalToolPaths(tool, args = {}) {
|
|
|
279
279
|
if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
|
|
280
280
|
for (const field of ["savePath", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
|
|
281
281
|
}
|
|
282
|
+
if (tool === "video.start" || tool === "video.restart") {
|
|
283
|
+
if (args.output !== undefined) normalized.output = normalize("output", args.output);
|
|
284
|
+
}
|
|
282
285
|
if (tool === "network.export") {
|
|
283
286
|
if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
|
|
284
287
|
if (args.jsonl !== undefined && typeof args.jsonl !== "boolean") throw transferError("network.export jsonl must be boolean", "SURF_PATH_FIELD");
|
|
@@ -356,6 +359,7 @@ function prepareRemoteTool(tool, args = {}) {
|
|
|
356
359
|
if (args.autoScreenshot === true && !AUTO_SCREENSHOT_TOOLS.includes(tool)) throw transferError(`autoScreenshot is not supported for ${tool}`, "SURF_PATH_DESCRIPTOR");
|
|
357
360
|
if (args.autoScreenshotOutput !== undefined) throw transferError("autoScreenshotOutput is internal", "SURF_PATH_DESCRIPTOR");
|
|
358
361
|
if (tool === "record") throw transferError("record is not supported with remote endpoint", "SURF_REMOTE_UNSUPPORTED");
|
|
362
|
+
if (typeof tool === "string" && tool.startsWith("video.")) throw transferError("video recording is not supported with remote endpoint", "SURF_REMOTE_UNSUPPORTED");
|
|
359
363
|
if (tool === "aistudio.build") throw transferError("aistudio.build is not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
|
|
360
364
|
if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
|
|
361
365
|
if (tool === "network.export") {
|
|
@@ -437,7 +441,7 @@ async function materializeRemoteTool({ tool, args: rawArgs = {}, metadata = null
|
|
|
437
441
|
const uploads = meta.uploads === undefined ? [] : meta.uploads;
|
|
438
442
|
const downloads = meta.downloads === undefined ? [] : meta.downloads;
|
|
439
443
|
if (!Array.isArray(pathRefs) || !Array.isArray(uploads) || !Array.isArray(downloads) || uploads.length > 1 || downloads.length > 1) throw transferError("invalid transfer metadata", "SURF_PATH_DESCRIPTOR");
|
|
440
|
-
if (tool === "record" || tool === "aistudio.build") throw transferError(`${tool} is not supported for remote connections`, "SURF_REMOTE_UNSUPPORTED");
|
|
444
|
+
if (tool === "record" || (typeof tool === "string" && tool.startsWith("video.")) || tool === "aistudio.build") throw transferError(`${tool} is not supported for remote connections`, "SURF_REMOTE_UNSUPPORTED");
|
|
441
445
|
if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
|
|
442
446
|
if (tool === "network.export") {
|
|
443
447
|
if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
|