surf-cli 2.18.0 → 2.20.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/host.cjs CHANGED
@@ -1,4 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ const LAUNCH_PROBE_ARGUMENT = "--surf-native-host-launch-probe";
3
+ const LAUNCH_PROBE_MARKER = "SURF_NATIVE_HOST_LAUNCH_PROBE_OK";
4
+
5
+ if (process.argv.length === 3 && process.argv[2] === LAUNCH_PROBE_ARGUMENT) {
6
+ process.stdout.write(`${LAUNCH_PROBE_MARKER}\n`);
7
+ process.exit(0);
8
+ }
9
+ if (process.argv.length === 3 && process.argv[2] === `${LAUNCH_PROBE_ARGUMENT}-distro`) {
10
+ process.stdout.write(`${LAUNCH_PROBE_MARKER}:${JSON.stringify(process.env.WSL_DISTRO_NAME || null)}\n`);
11
+ process.exit(0);
12
+ }
13
+
2
14
  const net = require("net");
3
15
  const fs = require("fs");
4
16
  const path = require("path");
@@ -15,11 +27,12 @@ const grokClient = require("./grok-client.cjs");
15
27
  const kimiClient = require("./kimi-client.cjs");
16
28
  const aistudioClient = require("./aistudio-client.cjs");
17
29
  const aistudioBuild = require("./aistudio-build.cjs");
18
- const { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage } = require("./host-helpers.cjs");
30
+ const { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, applySemanticExpectedIdentity } = require("./host-helpers.cjs");
19
31
  const { createOracleHost } = require("./oracle-host.cjs");
20
32
 
21
33
  const IS_WIN = process.platform === "win32";
22
34
  const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
35
+ const { takeFrames } = require("./stdin-frames.cjs");
23
36
  const { parseListenEndpoint } = require("./listener.cjs");
24
37
  const { getStateDir } = require("./remote-auth.cjs");
25
38
  const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
@@ -843,7 +856,7 @@ function currentFrameContext(request) {
843
856
  function applyFrameContextToMessage(request, extensionMessage) {
844
857
  if (!extensionMessage || !FRAME_CONTEXT_MESSAGE_TYPES.has(extensionMessage.type)) return;
845
858
  const context = currentFrameContext(request);
846
- if (context) extensionMessage.frameId = context.frameId;
859
+ if (context && !Number.isInteger(extensionMessage.frameId)) extensionMessage.frameId = context.frameId;
847
860
  }
848
861
 
849
862
  function persistFrameContext(request, frameId, url) {
@@ -1496,6 +1509,7 @@ async function executeMappedHostTool(request, tool, args, tabId) {
1496
1509
  if (!extensionMsg) throw new Error(`Unknown tool: ${tool}`);
1497
1510
  if (request.target?.strict) extensionMsg.strictTarget = true;
1498
1511
  applyFrameContextToMessage(request, extensionMsg);
1512
+ applySemanticExpectedIdentity(request, extensionMsg, args);
1499
1513
  if (extensionMsg.type === "UNSUPPORTED_ACTION") throw new Error(extensionMsg.message);
1500
1514
  if (extensionMsg.type === "LOCAL_WAIT") {
1501
1515
  await abortableDelay(extensionMsg.seconds * 1000, request.signal);
@@ -1719,6 +1733,9 @@ function sendToolResponse(socket, id, result, error) {
1719
1733
  let output = result;
1720
1734
  try {
1721
1735
  if (!error) output = await sendRequestDownloads(context, request, result);
1736
+ if (!error && output?.semanticObservation?.identity && request?.target) {
1737
+ output.semanticObservation.identity.browserEpoch = request.target.browserEpoch;
1738
+ }
1722
1739
  } catch (transferFailure) {
1723
1740
  finalError = transferFailure.message;
1724
1741
  }
@@ -1765,7 +1782,12 @@ function sendToolResponse(socket, id, result, error) {
1765
1782
  }
1766
1783
  if (request?.notice) response.notice = request.notice;
1767
1784
  if (formattedError) response.error = formattedError;
1768
- else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1785
+ else {
1786
+ response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1787
+ if (request?.tool === "tab.new" && Number.isInteger(output?.tabId) && output.tabId > 0) {
1788
+ response.result.tabId = output.tabId;
1789
+ }
1790
+ }
1769
1791
  if (!context?.closed) await sendSocket(socket, response);
1770
1792
  })().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
1771
1793
  }
@@ -1899,6 +1921,12 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1899
1921
  }
1900
1922
  if (requestContext.target?.strict) extensionMsg.strictTarget = true;
1901
1923
  applyFrameContextToMessage(requestContext, extensionMsg);
1924
+ try {
1925
+ applySemanticExpectedIdentity(requestContext, extensionMsg, args);
1926
+ } catch (error) {
1927
+ sendToolResponse(socket, originalId, null, error);
1928
+ return;
1929
+ }
1902
1930
 
1903
1931
  if (extensionMsg.type === "UNSUPPORTED_ACTION") {
1904
1932
  sendToolResponse(socket, originalId, null, extensionMsg.message);
@@ -2805,25 +2833,23 @@ function writeMessage(msg) {
2805
2833
  let inputBuffer = Buffer.alloc(0);
2806
2834
 
2807
2835
  function processInput() {
2808
- while (inputBuffer.length >= 4) {
2809
- const msgLen = inputBuffer.readUInt32LE(0);
2810
- if (inputBuffer.length < 4 + msgLen) break;
2811
-
2812
- const jsonStr = inputBuffer.slice(4, 4 + msgLen).toString("utf8");
2813
- inputBuffer = inputBuffer.slice(4 + msgLen);
2814
-
2836
+ // Take every complete frame out of the buffer before dispatching: one
2837
+ // chunk routinely carries a TARGET_EVENT and the reply to a tool request.
2838
+ const { frames, rest } = takeFrames(inputBuffer);
2839
+ inputBuffer = rest;
2840
+ for (const jsonStr of frames) {
2815
2841
  try {
2816
2842
  const msg = JSON.parse(jsonStr);
2817
2843
  log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
2818
2844
 
2819
2845
  if (msg.type === "EXTENSION_HELLO") {
2820
2846
  setBrowserIdentity(msg);
2821
- return;
2847
+ continue;
2822
2848
  }
2823
2849
 
2824
2850
  if (msg.type === "TARGET_EVENT") {
2825
2851
  handleTargetEvent(msg);
2826
- return;
2852
+ continue;
2827
2853
  }
2828
2854
 
2829
2855
  if (msg.type === "GET_AUTH") {
@@ -2847,12 +2873,12 @@ function processInput() {
2847
2873
  hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
2848
2874
  });
2849
2875
  }
2850
- return;
2876
+ continue;
2851
2877
  }
2852
2878
 
2853
2879
  if (msg.type === "API_REQUEST") {
2854
2880
  handleApiRequest(msg, writeMessage);
2855
- return;
2881
+ continue;
2856
2882
  }
2857
2883
 
2858
2884
  if (msg.type === "PLAYBOOK_WATCH_EVENT") {
@@ -2865,14 +2891,14 @@ function processInput() {
2865
2891
  tabId: msg.tabId,
2866
2892
  timestamp: msg.timestamp || new Date().toISOString(),
2867
2893
  });
2868
- return;
2894
+ continue;
2869
2895
  }
2870
2896
 
2871
2897
  if (msg.type === "VIDEO_FRAME") {
2872
2898
  if (activeVideoRecorder && msg.recorderId === activeVideoRecorder.recorderId && msg.tabId === activeVideoRecorder.tabId) {
2873
2899
  activeVideoRecorder.recorder.addFrame(msg.data, Number.isFinite(msg.receivedAt) ? msg.receivedAt : Date.now());
2874
2900
  }
2875
- return;
2901
+ continue;
2876
2902
  }
2877
2903
 
2878
2904
  if (msg.type === "VIDEO_ERROR") {
@@ -2882,7 +2908,7 @@ function processInput() {
2882
2908
  msg.error || "Video screencast failed",
2883
2909
  ));
2884
2910
  }
2885
- return;
2911
+ continue;
2886
2912
  }
2887
2913
 
2888
2914
  if (msg.type === "STREAM_EVENT") {
@@ -2894,7 +2920,7 @@ function processInput() {
2894
2920
  stream.socket.destroy(error);
2895
2921
  });
2896
2922
  }
2897
- return;
2923
+ continue;
2898
2924
  }
2899
2925
 
2900
2926
  if (msg.type === "STREAM_ERROR") {
@@ -2907,7 +2933,7 @@ function processInput() {
2907
2933
  })
2908
2934
  .finally(() => stopActiveStream(msg.streamId));
2909
2935
  }
2910
- return;
2936
+ continue;
2911
2937
  }
2912
2938
 
2913
2939
 
@@ -2920,13 +2946,13 @@ function processInput() {
2920
2946
  if (topLevelResponse && request?.context) {
2921
2947
  completeOwnedRequest(request.context, request.id, "cleanup-settled");
2922
2948
  }
2923
- return;
2949
+ continue;
2924
2950
  }
2925
2951
  handleFrameContextFailure(pending.request, msg);
2926
2952
  updateFrameContextFromResult(pending.request, pending.tool, msg);
2927
2953
  if (pending.resolve || pending.onComplete) {
2928
2954
  pendingToolRequests.resolve(msg.id, msg);
2929
- return;
2955
+ continue;
2930
2956
  }
2931
2957
  pendingToolRequests.delete(msg.id);
2932
2958
  {
@@ -2935,7 +2961,11 @@ function processInput() {
2935
2961
  const tabId = storedTabId || msg._resolvedTabId;
2936
2962
  const failAutoScreenshot = (message) => pending.autoScreenshotOutput
2937
2963
  ? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
2938
- : sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: message }, null);
2964
+ : sendToolResponse(socket, originalId, {
2965
+ ...msg,
2966
+ screenshotError: message,
2967
+ autoScreenshotError: message,
2968
+ }, null);
2939
2969
 
2940
2970
  if (pending.networkExport && Array.isArray(msg.entries)) {
2941
2971
  try {
@@ -3026,7 +3056,7 @@ function processInput() {
3026
3056
  }
3027
3057
  })
3028
3058
  .catch((error) => failAutoScreenshot(error.message));
3029
- return;
3059
+ continue;
3030
3060
  } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
3031
3061
  failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
3032
3062
  } else if (msg.results && msg.savePath) {
@@ -159,6 +159,27 @@ const TOOL_SCHEMAS = {
159
159
  timeout: z.number().optional().describe("Max wait time in ms")
160
160
  }
161
161
  },
162
+ "wait.ready": {
163
+ desc: "Wait until the page is ready, or fail fast with a typed state (login, challenge, not-found, error)",
164
+ schema: {
165
+ selector: z.string().optional().describe("Visible CSS selector that marks a ready page"),
166
+ text: z.string().optional().describe("Page text that marks a ready page"),
167
+ urlPrefix: z.string().optional().describe("Expected URL prefix; anything else is a bounce"),
168
+ emptyText: z.string().optional().describe("Text of an explicit no-results render (state 'empty')"),
169
+ accept: z.string().optional().describe("Negative states to return instead of fail, comma-separated"),
170
+ timeout: z.number().optional().describe("Max wait time in ms (default 20000, max 120000)"),
171
+ interval: z.number().optional().describe("Poll interval in ms (default 400)")
172
+ }
173
+ },
174
+ "page.readiness": {
175
+ desc: "Classify the page once: ready, empty, loading, login, challenge, not-found, error",
176
+ schema: {
177
+ selector: z.string().optional().describe("Visible CSS selector that marks a ready page"),
178
+ text: z.string().optional().describe("Page text that marks a ready page"),
179
+ urlPrefix: z.string().optional().describe("Expected URL prefix"),
180
+ emptyText: z.string().optional().describe("Text of an explicit no-results render")
181
+ }
182
+ },
162
183
  "wait.load": {
163
184
  desc: "Wait for page to fully load",
164
185
  schema: { timeout: z.number().optional().describe("Max wait time in ms") }
@@ -242,6 +263,10 @@ const TOOL_SCHEMAS = {
242
263
  desc: "List all frames in page",
243
264
  schema: {}
244
265
  },
266
+ "frame.diagnose": {
267
+ desc: "Compare DOM iframes, extension frames (with content-script reachability) and the CDP frame tree, with warnings",
268
+ schema: {}
269
+ },
245
270
  "frame.js": {
246
271
  desc: "Execute JS in specific frame",
247
272
  schema: {
@@ -0,0 +1,69 @@
1
+ const { execFileSync } = require("child_process");
2
+ const { runWindowsExecutable } = require("../scripts/windows-interop.cjs");
3
+
4
+ const LAUNCH_PROBE_ARGUMENT = "--surf-native-host-launch-probe";
5
+ const LAUNCH_PROBE_MARKER = "SURF_NATIVE_HOST_LAUNCH_PROBE_OK";
6
+ const WRAPPER_PROBE_CAPABILITY_MARKER = "rem SURF_NATIVE_HOST_LAUNCH_PROBE_V1";
7
+ const LAUNCH_PROBE_TIMEOUT_MS = 5000;
8
+
9
+ function renderWslWrapper(nodePath, hostPath, distro) {
10
+ const path = require("path");
11
+ for (const value of [nodePath, hostPath, distro].filter((value) => value !== undefined)) {
12
+ if (/["%!\r\n]/.test(value)) throw new Error("WSL wrapper path or distro contains unsupported batch characters");
13
+ }
14
+ const distroArg = distro ? ` -d "${distro}"` : "";
15
+ return `@echo off\r\n${WRAPPER_PROBE_CAPABILITY_MARKER}\r\nwsl.exe${distroArg} --cd "${path.dirname(hostPath)}" --exec "${nodePath}" "${hostPath}" %*\r\n`;
16
+ }
17
+
18
+ function probeWindowsWrapper(wrapperPath, deps = {}) {
19
+ if (!/^[a-z]:\\[^"%\r\n]*\.cmd$/i.test(wrapperPath) || /[&|<>^!]/.test(wrapperPath)) {
20
+ throw new Error("Native host wrapper path cannot be safely passed to cmd.exe");
21
+ }
22
+ let output;
23
+ try {
24
+ output = runWindowsExecutable(
25
+ "cmd.exe",
26
+ ["/d", "/s", "/c", `""${wrapperPath}" ${LAUNCH_PROBE_ARGUMENT}${deps.verifyDistro ? "-distro" : ""}"`],
27
+ {
28
+ execFileSync: deps.execFileSync || execFileSync,
29
+ allowWslFallback: true,
30
+ execOptions: {
31
+ encoding: "utf8",
32
+ timeout: deps.timeoutMs ?? LAUNCH_PROBE_TIMEOUT_MS,
33
+ maxBuffer: 64 * 1024,
34
+ windowsHide: true,
35
+ },
36
+ },
37
+ );
38
+ } catch (error) {
39
+ throw new Error(`Native host wrapper launch probe failed: ${error.message}`);
40
+ }
41
+
42
+ const response = String(output).trim();
43
+ if (deps.verifyDistro) {
44
+ if (!response.startsWith(`${LAUNCH_PROBE_MARKER}:`)) {
45
+ throw new Error("Native host wrapper launch probe failed: host returned unexpected output");
46
+ }
47
+ let distro;
48
+ try {
49
+ distro = JSON.parse(response.slice(LAUNCH_PROBE_MARKER.length + 1));
50
+ } catch {
51
+ throw new Error("Native host wrapper launch probe failed: invalid distro identity");
52
+ }
53
+ if (distro !== null && (typeof distro !== "string" || !distro)) {
54
+ throw new Error("Native host wrapper launch probe failed: invalid distro identity");
55
+ }
56
+ return distro;
57
+ }
58
+ if (response !== LAUNCH_PROBE_MARKER) {
59
+ throw new Error("Native host wrapper launch probe failed: host returned unexpected output");
60
+ }
61
+ }
62
+
63
+ module.exports = {
64
+ LAUNCH_PROBE_ARGUMENT,
65
+ LAUNCH_PROBE_MARKER,
66
+ WRAPPER_PROBE_CAPABILITY_MARKER,
67
+ renderWslWrapper,
68
+ probeWindowsWrapper,
69
+ };
@@ -30,6 +30,19 @@ function assertWithin(root, targetPath) {
30
30
  return resolvedTarget;
31
31
  }
32
32
 
33
+ function assertPrivatePath(targetPath, root, allowMissing) {
34
+ const resolvedRoot = path.resolve(root);
35
+ const resolvedTarget = assertWithin(resolvedRoot, targetPath);
36
+ const relative = path.relative(resolvedRoot, resolvedTarget);
37
+ let current = resolvedRoot;
38
+ for (const segment of ["", ...relative.split(path.sep).filter(Boolean)]) {
39
+ if (segment) current = path.join(current, segment);
40
+ const stat = assertNotSymlink(current, allowMissing);
41
+ if (!stat) return null;
42
+ }
43
+ return fs.lstatSync(resolvedTarget);
44
+ }
45
+
33
46
  function ensurePrivateDir(dirPath, root = getPrivateStateRoot()) {
34
47
  const resolvedRoot = path.resolve(root);
35
48
  const resolvedDir = assertWithin(resolvedRoot, dirPath);
@@ -129,13 +142,23 @@ function readPrivateFile(filePath, options = {}) {
129
142
  const resolved = path.resolve(filePath);
130
143
  const root = options.root || getPrivateStateRoot();
131
144
  assertWithin(root, resolved);
132
- const stat = assertNotSymlink(resolved, options.allowMissing === true);
145
+ const stat = assertPrivatePath(resolved, root, options.allowMissing === true);
133
146
  if (!stat) return options.fallback;
134
147
  if (!stat.isFile()) throw new Error(`private state path is not a file: ${resolved}`);
135
148
  if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error(`private state file permissions are too broad: ${resolved}`);
136
149
  return fs.readFileSync(resolved, options.encoding || null);
137
150
  }
138
151
 
152
+ function removePrivateFile(filePath, options = {}) {
153
+ const resolved = path.resolve(filePath);
154
+ const root = options.root || getPrivateStateRoot();
155
+ const stat = assertPrivatePath(resolved, root, true);
156
+ if (!stat) return false;
157
+ if (!stat.isFile()) throw new Error(`private state path is not a file: ${resolved}`);
158
+ fs.unlinkSync(resolved);
159
+ return true;
160
+ }
161
+
139
162
  function readPrivateJson(filePath, fallback = null, options = {}) {
140
163
  const content = readPrivateFile(filePath, { ...options, allowMissing: true, fallback: null, encoding: "utf8" });
141
164
  return content === null ? fallback : JSON.parse(content);
@@ -152,5 +175,6 @@ module.exports = {
152
175
  privateStatePath,
153
176
  readPrivateFile,
154
177
  readPrivateJson,
178
+ removePrivateFile,
155
179
  writePrivateFileExclusive,
156
180
  };
@@ -0,0 +1,33 @@
1
+ function isPlainObject(value) {
2
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
3
+ const proto = Object.getPrototypeOf(value);
4
+ return proto === Object.prototype || proto === null;
5
+ }
6
+
7
+ function parseScriptOptions(input) {
8
+ if (input === undefined || input === null || input === "") return {};
9
+ if (input === true) throw new Error("--options needs a JSON object value");
10
+ let value = input;
11
+ if (typeof input === "string") {
12
+ try {
13
+ value = JSON.parse(input);
14
+ } catch (error) {
15
+ throw new Error(`--options is not valid JSON: ${error.message}`);
16
+ }
17
+ }
18
+ if (!isPlainObject(value)) {
19
+ throw new Error("--options must be a JSON object, e.g. '{\"limit\": 20}'");
20
+ }
21
+ // Round-trip so functions, undefined and prototypes cannot leak into the page.
22
+ return JSON.parse(JSON.stringify(value));
23
+ }
24
+
25
+ function applyOptionsPrelude(code, options) {
26
+ const normalized = parseScriptOptions(options);
27
+ const prelude = `const SURF_OPTIONS = Object.freeze(JSON.parse(${JSON.stringify(JSON.stringify(normalized))}));\n`;
28
+ const strict = code.match(/^\s*(["'])use strict\1\s*;/);
29
+ if (!strict) return `${prelude}${code}`;
30
+ return `${strict[0]}\n${prelude}${code.slice(strict[0].length)}`;
31
+ }
32
+
33
+ module.exports = { applyOptionsPrelude, parseScriptOptions };