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.
@@ -15,6 +15,9 @@ const QUEUE_TIMEOUT_MS = 60000;
15
15
  const DEFAULT_DEADLINE_MS = 60000;
16
16
  const MAX_DEADLINE_MS = 50 * 60 * 1000;
17
17
  const CLEANUP_GRACE_MS = 60000;
18
+ const READINESS_DEFAULT_TIMEOUT_MS = 20000;
19
+ const READINESS_MAX_TIMEOUT_MS = 120000;
20
+ const READINESS_DEADLINE_GRACE_MS = 5000;
18
21
  const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
19
22
  ai: 300,
20
23
  aistudio: 300,
@@ -29,6 +32,13 @@ const PROVIDER_DEFAULT_TIMEOUT_SECONDS = {
29
32
  };
30
33
 
31
34
  function resolveRequestDeadlineMs(tool, args = {}) {
35
+ if (tool === "wait.ready") {
36
+ const requestedMs = Number(args?.timeout);
37
+ const timeoutMs = Number.isFinite(requestedMs) && requestedMs > 0
38
+ ? Math.min(requestedMs, READINESS_MAX_TIMEOUT_MS)
39
+ : READINESS_DEFAULT_TIMEOUT_MS;
40
+ return timeoutMs + READINESS_DEADLINE_GRACE_MS;
41
+ }
32
42
  const defaultSeconds = PROVIDER_DEFAULT_TIMEOUT_SECONDS[tool];
33
43
  if (defaultSeconds === undefined) return DEFAULT_DEADLINE_MS;
34
44
  const rawTimeout = tool === "playbook.run" && args && typeof args === "object" && !Array.isArray(args)
package/native/host.cjs CHANGED
@@ -20,6 +20,7 @@ const { createOracleHost } = require("./oracle-host.cjs");
20
20
 
21
21
  const IS_WIN = process.platform === "win32";
22
22
  const { SOCKET_PATH, SURF_TMP } = require("./socket-path.cjs");
23
+ const { takeFrames } = require("./stdin-frames.cjs");
23
24
  const { parseListenEndpoint } = require("./listener.cjs");
24
25
  const { getStateDir } = require("./remote-auth.cjs");
25
26
  const { createFrameParser, createServerAuthSession, createSocketWriter, isClientAuthorized, writeFrame, MAX_FRAME_BYTES } = require("./remote-transport.cjs");
@@ -1765,7 +1766,12 @@ function sendToolResponse(socket, id, result, error) {
1765
1766
  }
1766
1767
  if (request?.notice) response.notice = request.notice;
1767
1768
  if (formattedError) response.error = formattedError;
1768
- else response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1769
+ else {
1770
+ response.result = { content: formatToolContent(output, log, { suppressImages: Boolean(context?.isRemote) }) };
1771
+ if (request?.tool === "tab.new" && Number.isInteger(output?.tabId) && output.tabId > 0) {
1772
+ response.result.tabId = output.tabId;
1773
+ }
1774
+ }
1769
1775
  if (!context?.closed) await sendSocket(socket, response);
1770
1776
  })().catch((sendError) => log(`Error sending tool_response: ${sendError.message}`));
1771
1777
  }
@@ -2805,25 +2811,23 @@ function writeMessage(msg) {
2805
2811
  let inputBuffer = Buffer.alloc(0);
2806
2812
 
2807
2813
  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
-
2814
+ // Take every complete frame out of the buffer before dispatching: one
2815
+ // chunk routinely carries a TARGET_EVENT and the reply to a tool request.
2816
+ const { frames, rest } = takeFrames(inputBuffer);
2817
+ inputBuffer = rest;
2818
+ for (const jsonStr of frames) {
2815
2819
  try {
2816
2820
  const msg = JSON.parse(jsonStr);
2817
2821
  log(`Received from extension: ${msg.type || "unknown"}${msg.id !== undefined ? ` id=${msg.id}` : ""}`);
2818
2822
 
2819
2823
  if (msg.type === "EXTENSION_HELLO") {
2820
2824
  setBrowserIdentity(msg);
2821
- return;
2825
+ continue;
2822
2826
  }
2823
2827
 
2824
2828
  if (msg.type === "TARGET_EVENT") {
2825
2829
  handleTargetEvent(msg);
2826
- return;
2830
+ continue;
2827
2831
  }
2828
2832
 
2829
2833
  if (msg.type === "GET_AUTH") {
@@ -2847,12 +2851,12 @@ function processInput() {
2847
2851
  hint: "Failed to read auth credentials. Run 'pi --login anthropic' in terminal to authenticate."
2848
2852
  });
2849
2853
  }
2850
- return;
2854
+ continue;
2851
2855
  }
2852
2856
 
2853
2857
  if (msg.type === "API_REQUEST") {
2854
2858
  handleApiRequest(msg, writeMessage);
2855
- return;
2859
+ continue;
2856
2860
  }
2857
2861
 
2858
2862
  if (msg.type === "PLAYBOOK_WATCH_EVENT") {
@@ -2865,14 +2869,14 @@ function processInput() {
2865
2869
  tabId: msg.tabId,
2866
2870
  timestamp: msg.timestamp || new Date().toISOString(),
2867
2871
  });
2868
- return;
2872
+ continue;
2869
2873
  }
2870
2874
 
2871
2875
  if (msg.type === "VIDEO_FRAME") {
2872
2876
  if (activeVideoRecorder && msg.recorderId === activeVideoRecorder.recorderId && msg.tabId === activeVideoRecorder.tabId) {
2873
2877
  activeVideoRecorder.recorder.addFrame(msg.data, Number.isFinite(msg.receivedAt) ? msg.receivedAt : Date.now());
2874
2878
  }
2875
- return;
2879
+ continue;
2876
2880
  }
2877
2881
 
2878
2882
  if (msg.type === "VIDEO_ERROR") {
@@ -2882,7 +2886,7 @@ function processInput() {
2882
2886
  msg.error || "Video screencast failed",
2883
2887
  ));
2884
2888
  }
2885
- return;
2889
+ continue;
2886
2890
  }
2887
2891
 
2888
2892
  if (msg.type === "STREAM_EVENT") {
@@ -2894,7 +2898,7 @@ function processInput() {
2894
2898
  stream.socket.destroy(error);
2895
2899
  });
2896
2900
  }
2897
- return;
2901
+ continue;
2898
2902
  }
2899
2903
 
2900
2904
  if (msg.type === "STREAM_ERROR") {
@@ -2907,7 +2911,7 @@ function processInput() {
2907
2911
  })
2908
2912
  .finally(() => stopActiveStream(msg.streamId));
2909
2913
  }
2910
- return;
2914
+ continue;
2911
2915
  }
2912
2916
 
2913
2917
 
@@ -2920,13 +2924,13 @@ function processInput() {
2920
2924
  if (topLevelResponse && request?.context) {
2921
2925
  completeOwnedRequest(request.context, request.id, "cleanup-settled");
2922
2926
  }
2923
- return;
2927
+ continue;
2924
2928
  }
2925
2929
  handleFrameContextFailure(pending.request, msg);
2926
2930
  updateFrameContextFromResult(pending.request, pending.tool, msg);
2927
2931
  if (pending.resolve || pending.onComplete) {
2928
2932
  pendingToolRequests.resolve(msg.id, msg);
2929
- return;
2933
+ continue;
2930
2934
  }
2931
2935
  pendingToolRequests.delete(msg.id);
2932
2936
  {
@@ -2935,7 +2939,11 @@ function processInput() {
2935
2939
  const tabId = storedTabId || msg._resolvedTabId;
2936
2940
  const failAutoScreenshot = (message) => pending.autoScreenshotOutput
2937
2941
  ? sendToolResponse(socket, originalId, null, `Auto-screenshot failed: ${message}`)
2938
- : sendToolResponse(socket, originalId, { ...msg, autoScreenshotError: message }, null);
2942
+ : sendToolResponse(socket, originalId, {
2943
+ ...msg,
2944
+ screenshotError: message,
2945
+ autoScreenshotError: message,
2946
+ }, null);
2939
2947
 
2940
2948
  if (pending.networkExport && Array.isArray(msg.entries)) {
2941
2949
  try {
@@ -3026,7 +3034,7 @@ function processInput() {
3026
3034
  }
3027
3035
  })
3028
3036
  .catch((error) => failAutoScreenshot(error.message));
3029
- return;
3037
+ continue;
3030
3038
  } else if (autoScreenshot && pending.autoScreenshotOutput && !msg.error) {
3031
3039
  failAutoScreenshot(tabId ? "screenshot response was invalid" : "no tab available");
3032
3040
  } 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,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 };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Native messaging framing: every message from the extension arrives as a
3
+ * 4-byte little-endian length followed by that many bytes of UTF-8 JSON.
4
+ *
5
+ * One stdin chunk routinely carries several complete frames (a TARGET_EVENT
6
+ * followed by the reply to a tool request is the common case), so a reader
7
+ * must take every complete frame out of the buffer before waiting for more
8
+ * input. Leaving one behind stalls that reply until the extension sends
9
+ * something else.
10
+ */
11
+
12
+ const HEADER_BYTES = 4;
13
+
14
+ /**
15
+ * Split `buffer` into complete frames and the unread remainder.
16
+ *
17
+ * @param {Buffer} buffer
18
+ * @returns {{ frames: string[], rest: Buffer }} decoded frame payloads in
19
+ * arrival order, and the bytes of any trailing partial frame.
20
+ */
21
+ function takeFrames(buffer) {
22
+ const frames = [];
23
+ let offset = 0;
24
+ while (buffer.length - offset >= HEADER_BYTES) {
25
+ const length = buffer.readUInt32LE(offset);
26
+ if (buffer.length - offset < HEADER_BYTES + length) break;
27
+ frames.push(buffer.subarray(offset + HEADER_BYTES, offset + HEADER_BYTES + length).toString("utf8"));
28
+ offset += HEADER_BYTES + length;
29
+ }
30
+ return { frames, rest: offset === 0 ? buffer : buffer.subarray(offset) };
31
+ }
32
+
33
+ module.exports = { takeFrames };
@@ -45,8 +45,8 @@ const TAB_TOOLS = new Set([
45
45
  "scroll", "scroll.top", "scroll.bottom", "scroll.to", "scroll.info", "scroll_to_position",
46
46
  "search", "locate.role", "locate.text", "locate.label", "element.styles",
47
47
  "js", "javascript_tool", "eval",
48
- "wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "health",
49
- "frame.list", "frame.switch", "frame.main", "frame.js",
48
+ "wait.element", "wait.url", "wait.network", "wait.dom", "wait.load", "wait.ready", "page.readiness", "health",
49
+ "frame.list", "frame.diagnose", "frame.switch", "frame.main", "frame.js",
50
50
  "dialog.accept", "dialog.dismiss", "dialog.info",
51
51
  "console", "network", "network.get", "network.body", "network.curl", "network.path",
52
52
  "network.origins", "network.clear", "network.stats", "network.export",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.18.0",
3
+ "version": "2.19.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -68,14 +68,14 @@
68
68
  "@biomejs/biome": "^2.5.4",
69
69
  "@types/chrome": "^0.2.2",
70
70
  "@types/node": "^26.1.2",
71
- "@vitest/coverage-v8": "^4.1.9",
72
- "@vitest/ui": "^4.1.9",
73
- "pi-subagents": "^0.58.0",
74
- "puppeteer": "25.9.0",
71
+ "@vitest/coverage-v8": "^5.0.0",
72
+ "@vitest/ui": "^5.0.0",
73
+ "pi-subagents": "^0.66.0",
74
+ "puppeteer": "25.10.0",
75
75
  "typebox": "^1.3.11",
76
76
  "typescript": "^7.0.2",
77
77
  "vite": "^8.1.4",
78
- "vitest": "^4.1.9"
78
+ "vitest": "^5.0.0"
79
79
  },
80
80
  "pi": {
81
81
  "extensions": [
@@ -7,6 +7,8 @@ description: Control Chrome browser via CLI for testing, automation, and debuggi
7
7
 
8
8
  Control Chrome browser via CLI or Unix socket.
9
9
 
10
+ Ordinary socket-backed CLI commands report top-level host tool-response errors on stderr with a supplied `[code]` on the first line and exit 1. `--json` additionally writes `{error:{code,message,details?}}` on stdout (missing code becomes `"error"`). `--soft-fail` instead keeps the original stderr warning, empty stdout and exit 0, even with `--json`. This does not cover local validation, transport/parser failures or compound-command errors: do not assume every failure produces JSON. Connection failures remain stderr-only and exit 1, including with `--soft-fail`.
11
+
10
12
  ## Native Host / Socket Notes
11
13
 
12
14
  For WSL2 with Windows Chrome, run `surf install <extension-id>` inside WSL2. Surf detects WSL2 and writes the Windows-side native messaging manifest plus a wrapper that launches the WSL host. Use `surf install <extension-id> --target linux` only for Linux browsers running inside WSLg.
@@ -34,9 +36,18 @@ surf --remote 100.101.102.103:4321 \
34
36
  --remote-credential ~/.config/surf/agent-macbook.json \
35
37
  page.read
36
38
 
39
+ # TLS is client-side and requires a TLS-terminating reverse proxy in front of SURF_LISTEN
40
+ surf --remote surf.example.com:443 --remote-tls \
41
+ --remote-tls-ca ~/.config/surf/private-ca.pem \
42
+ --remote-credential ~/.config/surf/agent-macbook.json page.read
43
+
37
44
  surf remote revoke agent-macbook # Run on the browser host
38
45
  ```
39
46
 
47
+ Environment equivalents are `SURF_REMOTE_TLS=1`, `SURF_REMOTE_TLS_CA`, and
48
+ `SURF_REMOTE_TLS_SERVER_NAME`. A custom CA replaces system roots; Ed25519 credentials remain
49
+ mandatory after TLS validation.
50
+
40
51
  Remote paths are client-local by default. `local:./file` is explicit client-local syntax; only `remote:/absolute/path` accesses the browser host directly. Remote transfer supports one upload or ChatGPT/Gemini input and one screenshot, network-export, or Gemini image output. Limits are 256 MiB per file, 512 MiB and 32 files per connection, and 256 KiB decoded chunks. `record`, `aistudio.build`, smoke screenshot directories, directories, and multi-file inputs are not supported remotely. Successful action screenshots and failure `--auto-capture` diagnostics are transferred back to client-local paths.
41
52
 
42
53
  ## CLI Quick Reference
@@ -321,6 +332,7 @@ surf page.text # Plain text content only
321
332
  surf page.html --strip-scripts # Rendered HTML without scripts
322
333
  surf page.save --selector "#artifact" --strip-scripts --output page.html # Save one static element
323
334
  surf page.state # Modals, loading state, scroll info
335
+ surf frame.diagnose # Why a selector misses: DOM iframes (incl. open shadow roots) vs extension frames vs CDP tree, with warnings; out-of-process frames need frame.switch, not frame.js
324
336
  ```
325
337
 
326
338
  ### Export Rendered HTML
@@ -398,8 +410,14 @@ surf wait.network # Wait for network idle
398
410
  surf wait.url "/success" # Wait for URL pattern
399
411
  surf wait.dom --stable 100 # Wait for DOM stability
400
412
  surf wait.load # Wait for page load complete
413
+ surf wait.ready --selector ".results" # Ready, or fail fast: login / challenge / not-found / error
414
+ surf wait.ready --url-prefix "https://app.example.com/" --empty-text "No results" # empty vs blocked
415
+ surf wait.ready --accept login --json # Return the negative state instead of failing
416
+ surf page.readiness --json # Classify the current page once (state + evidence)
401
417
  ```
402
418
 
419
+ Typed readiness states replace "the selector never appeared": exit codes carry `page_login`, `page_challenge`, `page_not_found`, `page_error` or `page_timeout`. Detection uses visible UI (a rendered password field, a login route, the page's wording, a URL outside `--url-prefix`), not site selectors.
420
+
403
421
  ## Dialog Handling
404
422
 
405
423
  ```bash