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/README.md CHANGED
@@ -135,6 +135,30 @@ SURF_REMOTE_CREDENTIAL=~/.config/surf/agent-macbook.json \
135
135
  surf tab.list
136
136
  ```
137
137
 
138
+ For a TLS-terminating reverse proxy in front of the native host's existing clear-TCP
139
+ listener, opt in on the client:
140
+
141
+ ```bash
142
+ surf --remote surf.example.com:443 --remote-tls \
143
+ --remote-credential ~/.config/surf/agent-macbook.json tab.list
144
+
145
+ # Private CA and an IP destination with a DNS certificate identity
146
+ surf --remote 100.101.102.103:443 --remote-tls \
147
+ --remote-tls-ca ~/.config/surf/private-ca.pem \
148
+ --remote-tls-server-name surf.example.com \
149
+ --remote-credential ~/.config/surf/agent-macbook.json tab.list
150
+ ```
151
+
152
+ `--remote-tls-ca` replaces Node's system roots rather than adding to them. DNS endpoints
153
+ use their hostname for SNI and certificate validation; IP endpoints omit SNI and validate
154
+ the certificate's IP SAN unless `--remote-tls-server-name` supplies a DNS identity. TLS
155
+ certificate validation finishes before the mandatory Ed25519 authentication. Surf has no
156
+ insecure mode, downgrade, or plaintext retry. `SURF_REMOTE_TLS=1` is the only accepted
157
+ environment spelling; unset it to disable TLS. There is no CLI negation for env-enabled TLS.
158
+ CLI values override `SURF_REMOTE_TLS_CA` and `SURF_REMOTE_TLS_SERVER_NAME` independently.
159
+ `SURF_LISTEN` remains a plaintext listener behind the reverse proxy; Surf does not terminate
160
+ TLS on the browser host.
161
+
138
162
  Surf performs mutual Ed25519 challenge-response with fresh nonces and checks authorization throughout the connection. A credential grants the same browser and host-file authority as a trusted local Surf user. Give each client its own credential, do not share it, and revoke it immediately if the client or file is lost:
139
163
 
140
164
  ```bash
@@ -162,7 +186,9 @@ Keep Tailscale policy restrictions as defense in depth. For example:
162
186
  }
163
187
  ```
164
188
 
165
- Adapt tags and ports to your Tailnet. Surf authentication does not replace Tailnet policy, and Surf does not add a separate TLS or SSH tunnel.
189
+ Adapt tags and ports to your Tailnet. Surf authentication does not replace Tailnet policy.
190
+ Optional outbound remote TLS protects the client-to-proxy connection; Surf does not add an
191
+ SSH tunnel or a TLS listener.
166
192
 
167
193
  **Operations and troubleshooting**
168
194
 
@@ -280,6 +306,13 @@ surf locate.role button --action click
280
306
  surf frame.main # Return to main page
281
307
  ```
282
308
 
309
+ When a selector never matches, `frame.diagnose` shows the three frame views side by side (DOM `<iframe>` elements, the extension's frames with content-script reachability, and the CDP frame tree) and explains the mismatches: `srcdoc`/`about:blank` frames (matched to their CDP frame by `name`/`id`), sandboxes without `allow-scripts`, cross-origin frames, out-of-process frames that the CDP tree does not list (`frame.js` cannot reach them; `frame.switch` and `page.read` can when the content script answers), and frames still loading. The DOM inventory walks open shadow roots, so frames rendered by custom elements are listed with their `shadowHost` path. The text report abbreviates long frame URLs; `--json` keeps them whole.
310
+
311
+ ```bash
312
+ surf frame.diagnose # Human-readable report with warnings
313
+ surf frame.diagnose --json # Full inventories
314
+ ```
315
+
283
316
  ### Interaction
284
317
 
285
318
  ```bash
@@ -569,12 +602,55 @@ surf wait 2 # Wait 2 seconds
569
602
  surf wait.element ".loaded" # Wait for element
570
603
  surf wait.network # Wait for network idle
571
604
  surf wait.url "/dashboard" # Wait for URL pattern
605
+ surf wait.ready --selector ".results" # Wait for content, fail fast on a bounce
606
+ surf page.readiness --json # Classify the page once
607
+ ```
608
+
609
+ `wait.ready` polls with a bounded budget and reports a typed state instead of timing out silently: `ready`, `empty` (the page showed its own no-results message, `--empty-text`), or one of the negative states `login`, `challenge` (anti-bot interstitial), `not-found`, `error`. A negative state exits non-zero with codes `page_login`, `page_challenge`, `page_not_found`, `page_error`; `page_timeout` reports the last observed state. Pass `--accept login` to return a state to the caller instead. Detection uses visible UI state (a rendered password field, a login-looking route, the page's own wording, `--url-prefix` bounces), never site-specific selectors.
610
+
611
+ ```bash
612
+ surf wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"
613
+ surf wait.ready --accept login --json # {"state":"login","evidence":[...]} instead of an error
572
614
  ```
573
615
 
616
+ ### Extracting structured data
617
+
618
+ `surf extract` composes existing client-side tools: it opens an owned tab,
619
+ waits for explicit readiness, runs a page script, validates its JSON result,
620
+ and closes the tab. It prints concise Markdown by default or structured JSON
621
+ with `--json`. Return an array, or an object containing a conventional row key
622
+ such as `rows`, `items`, or `results`; use `--rows <key>` for another key.
623
+
624
+ ```bash
625
+ surf extract "https://example.com/list" --file rows.js --ready-selector ".item"
626
+ surf extract "https://example.com/search" --file rows.js --options '{"limit":20}' --empty-text "No results" --json
627
+ surf extract --tab-id 42 --code 'return [...document.querySelectorAll("h2")].map(h => ({title: h.textContent}))'
628
+ ```
629
+
630
+ Owned-tab failures always attempt cleanup. Zero rows retry unless
631
+ `--allow-empty` is set or `--empty-text` identifies the page's accepted empty
632
+ state. Fresh-tab retries are bounded (`--retry`, default 1, maximum 5) and are
633
+ limited to readiness timeouts, zero rows, and lost tab/execution-context
634
+ failures. Login, challenge, not-found, page-error, script/output, and cleanup
635
+ failures do not retry. `--tab-id` and `--session` target an existing page and
636
+ never retry or close it; `--keep-tab` preserves a successfully owned tab.
637
+
638
+ Extract is intended for read-only or otherwise idempotent caller scripts.
639
+ JavaScript is not inherently read-only: a retry can replay the script, so avoid
640
+ mutations or make them idempotent.
641
+
574
642
  ### Other
575
643
 
644
+ `js` and `frame.js` accept `--options '{"limit": 20}'` with inline code or
645
+ `--file`. This defines `SURF_OPTIONS` by parsing the JSON and freezing the
646
+ result; use an explicit `return` for the script's result. The freeze is shallow.
647
+ Invalid JSON and non-object values are rejected before sending a request;
648
+ `--options ''` defines an empty object. Without `--options`, code is unchanged.
649
+
576
650
  ```bash
577
651
  surf js "return document.title" # Execute JavaScript
652
+ surf js "piHelpers.setValue(document.querySelector('#q'), 'hello')" # Native value setter + input/change events
653
+ surf js --file script.js --options '{"limit": 20}' # Script reads SURF_OPTIONS.limit
578
654
  surf record --duration 2000 --fps 10 --output /tmp/anim.gif # Animated GIF capture
579
655
  surf animate-audit --selector ".thing" --duration 2000 --fps 10 # JSON animation timeline
580
656
  surf perf-audit --duration 3000 --output /tmp/perf.json # PerformanceObserver snapshot
@@ -811,12 +887,32 @@ Generated manifests declare provenance and authentication environment inputs. Su
811
887
  --window-id <id> # Target a specific window
812
888
  --no-wait # Return tab_busy/browser_busy instead of queueing
813
889
  --json # Raw JSON including resolved target metadata
814
- --soft-fail # Warn instead of error (exit 0) on restricted pages
890
+ --soft-fail # Host tool errors: stderr warning, exit 0, no JSON error output
815
891
  --no-lock # Bypass the legacy lock for compound client-side commands
816
892
  --no-screenshot # Skip auto-screenshot after actions
817
893
  --full # Full resolution screenshots (skip resize)
818
894
  ```
819
895
 
896
+ ### Host tool-response errors
897
+
898
+ For ordinary socket-backed commands, a host response with a top-level `error`
899
+ exits 1 and prints `Error: ...` on stderr. A supplied code is appended as `[code]`
900
+ to the first line unless already present there; subsequent recovery lines are
901
+ preserved. Without a code, no suffix is added.
902
+
903
+ `--json` additionally writes `{"error":{"code":"...","message":"...","details":{...}}}`
904
+ to stdout, while retaining stderr and exit 1. The JSON code defaults to `"error"`;
905
+ the message uses the host's message, or the first display line if absent. Optional
906
+ details retain the host's fields except redundant `code` and `message` fields.
907
+
908
+ `--soft-fail` takes precedence: the original host display text is printed as a
909
+ stderr warning, without adding a code, stdout stays empty even with `--json`,
910
+ and the command exits 0. This is **not a universal JSON error envelope**: local
911
+ validation, transport/parser failures, compound commands and errors embedded in
912
+ successful result payloads retain their existing behavior. In particular, a
913
+ connection failure still prints stderr, leaves stdout empty and exits 1 with
914
+ `--json`, even with `--soft-fail`.
915
+
820
916
  ## Environment Variables
821
917
 
822
918
  ```bash
@@ -826,6 +922,9 @@ SURF_SESSION # Default named browser session for tab-scoped command
826
922
  SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
827
923
  SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
828
924
  SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
925
+ SURF_REMOTE_TLS # Exactly 1 enables TLS for a selected remote endpoint
926
+ SURF_REMOTE_TLS_CA # Custom CA bundle that replaces system roots
927
+ SURF_REMOTE_TLS_SERVER_NAME # DNS SNI and certificate identity override
829
928
  SURF_REMOTE_STATE_DIR # Host identity/authorization directory (default: ~/.surf/remote)
830
929
  SURF_LISTEN # Native-host Tailnet bind address as <tailscale-ip>:<port>
831
930
  SURF_SOCKET_MODE # Advanced POSIX local socket mode: 600 (default) or 660
@@ -841,6 +940,9 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
841
940
  - `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI when separate browser/profile instances need hard isolation.
842
941
  - `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
843
942
  - `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
943
+ - `SURF_REMOTE_TLS`: Set exactly `1` for TLS through a terminating reverse proxy; `--remote-tls` also enables it and cannot negate an env-enabled setting.
944
+ - `SURF_REMOTE_TLS_CA`: CA bundle for remote TLS, replacing system roots. `--remote-tls-ca <path>` overrides it.
945
+ - `SURF_REMOTE_TLS_SERVER_NAME`: DNS SNI and certificate identity override. `--remote-tls-server-name <name>` overrides it.
844
946
  - `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
845
947
  - `SURF_LISTEN`: Native-host listener address on the browser machine. Use `surf install ... --listen <tailscale-ip>:<port>` to persist it in that host's wrapper.
846
948
  - `SURF_SOCKET_MODE` / `SURF_SOCKET_GROUP`: Advanced POSIX native-host settings. Use `surf install ... --socket-mode 660 --socket-group <group>` to persist group access; mode `660` grants full Surf authority to every member of that group.
@@ -938,11 +1040,11 @@ echo '{"type":"tool_request","method":"execute_tool","params":{"tool":"tab.list"
938
1040
  | `window.*` | `new`, `list`, `focus`, `close`, `resize` |
939
1041
  | `tab.*` | `list`, `new`, `switch`, `close`, `name`, `unname`, `named`, `group`, `ungroup`, `groups`, `reload` |
940
1042
  | `scroll.*` | `top`, `bottom`, `to`, `info` |
941
- | `page.*` | `read`, `text`, `state` |
1043
+ | `page.*` | `read`, `text`, `state`, `readiness` |
942
1044
  | `locate.*` | `role`, `text`, `label` |
943
1045
  | `element.*` | `styles` |
944
- | `frame.*` | `list`, `switch`, `main`, `js` |
945
- | `wait.*` | `element`, `network`, `url`, `dom`, `load` |
1046
+ | `frame.*` | `list`, `diagnose`, `switch`, `main`, `js` |
1047
+ | `wait.*` | `element`, `network`, `url`, `dom`, `load`, `ready` |
946
1048
  | `cookie` / `cookie.*` | `list`, `get`, `set`, `clear`, `delete` |
947
1049
  | `bookmark.*` | `add`, `remove`, `list` |
948
1050
  | `history.*` | `list`, `search` |
package/native/cli.cjs CHANGED
@@ -16,7 +16,9 @@ const {
16
16
  validateWorkflowArgs,
17
17
  validateWorkflowFile,
18
18
  } = require("./workflow-definition.cjs");
19
- const { executeDoSteps } = require("./do-executor.cjs");
19
+ const { executeDoSteps, sendDoRequest } = require("./do-executor.cjs");
20
+ const { runExtraction, renderExtractionMarkdown } = require("./extract.cjs");
21
+ const { applyOptionsPrelude, parseScriptOptions } = require("./script-options.cjs");
20
22
  const { openClientTransport } = require("./client-transport.cjs");
21
23
  const { version: VERSION } = require("../package.json");
22
24
  const {
@@ -73,7 +75,7 @@ function positiveIdFlag(argv, flag) {
73
75
  return parsed;
74
76
  }
75
77
 
76
- function resolveEarlyTargetOptions(argv, { allowWindow = true } = {}) {
78
+ function resolveEarlyTargetOptions(argv, { allowWindow = true, allowEnvironmentSession = true } = {}) {
77
79
  const explicitSession = flagValue(argv, "--session");
78
80
  const tabId = positiveIdFlag(argv, "--tab-id");
79
81
  const windowId = allowWindow ? positiveIdFlag(argv, "--window-id") : undefined;
@@ -82,7 +84,7 @@ function resolveEarlyTargetOptions(argv, { allowWindow = true } = {}) {
82
84
  process.exit(1);
83
85
  }
84
86
  const environmentSession = process.env.SURF_SESSION;
85
- const session = explicitSession || (!tabId && !windowId ? environmentSession : undefined);
87
+ const session = explicitSession || (allowEnvironmentSession && !tabId && !windowId ? environmentSession : undefined);
86
88
  return {
87
89
  ...(session ? { session, sessionSource: explicitSession ? "explicit" : "environment" } : {}),
88
90
  ...(tabId ? { tabId } : {}),
@@ -721,6 +723,17 @@ const TOOLS = {
721
723
  examples: [{ cmd: "page.save --output page.html", desc: "Save current document HTML" }],
722
724
  },
723
725
  "page.state": { desc: "Get page state (modals, loading, etc.)", args: [] },
726
+ "page.readiness": {
727
+ desc: "Classify the page once: ready, empty, loading, login, challenge, not-found, error",
728
+ args: [],
729
+ opts: {
730
+ selector: "Visible CSS selector that marks a ready page",
731
+ text: "Page text that marks a ready page",
732
+ "url-prefix": "Expected URL prefix",
733
+ "empty-text": "Text of an explicit no-results render",
734
+ },
735
+ examples: [{ cmd: "page.readiness --json", desc: "State plus evidence as JSON" }]
736
+ },
724
737
  }
725
738
  },
726
739
  locate: {
@@ -824,6 +837,24 @@ const TOOLS = {
824
837
  },
825
838
  "wait.dom": { desc: "Wait for DOM to stabilize", args: [], opts: { stable: "Stability window in ms (default: 100)", timeout: "Max wait time in ms" } },
826
839
  "wait.load": { desc: "Wait for page to fully load", args: [], opts: { timeout: "Max wait time in ms (default: 30000)" } },
840
+ "wait.ready": {
841
+ desc: "Wait until the page is ready, or fail fast with a typed state (challenge, login, not-found, error)",
842
+ args: [],
843
+ opts: {
844
+ selector: "Visible CSS selector that marks a ready page",
845
+ text: "Page text that marks a ready page",
846
+ "url-prefix": "Expected URL prefix; anything else is a bounce",
847
+ "empty-text": "Text of an explicit no-results render (reports state 'empty')",
848
+ accept: "Negative states to return instead of fail (comma list)",
849
+ timeout: "Max wait time in ms (default: 20000, max: 120000)",
850
+ interval: "Poll interval in ms (default: 400)",
851
+ },
852
+ examples: [
853
+ { cmd: 'wait.ready --selector ".results"', desc: "Wait for content; fail fast on a login bounce" },
854
+ { cmd: 'wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"', desc: "Distinguish empty from blocked" },
855
+ { cmd: "wait.ready --accept login --json", desc: "Return the login state to the caller" },
856
+ ]
857
+ },
827
858
  }
828
859
  },
829
860
  input: {
@@ -878,17 +909,52 @@ const TOOLS = {
878
909
  "drag": { desc: "Drag between points", args: [], opts: { from: "Start x,y", to: "End x,y" } },
879
910
  }
880
911
  },
912
+ extract: {
913
+ desc: "Scripted extraction in an owned tab",
914
+ commands: {
915
+ "extract": {
916
+ desc: "Open a URL in a fresh tab, wait until it is ready, run a page-side script that returns JSON, print rows",
917
+ args: ["url"],
918
+ opts: {
919
+ file: "Script file; must `return` JSON (an array, or an object with a rows/items/results array)",
920
+ code: "Inline script instead of --file",
921
+ options: "JSON object exposed to the script as SURF_OPTIONS",
922
+ "options-file": "Read the options object from a JSON file",
923
+ "ready-selector": "wait.ready --selector before extracting",
924
+ "ready-text": "wait.ready --text before extracting",
925
+ "ready-url-prefix": "wait.ready --url-prefix; a different URL is a bounce",
926
+ "empty-text": "wait.ready --empty-text; lets an explicit no-results page pass the zero-rows check",
927
+ "ready-timeout": "Readiness timeout in ms (default: 20000)",
928
+ "ready-interval": "Readiness polling interval in ms (default: 400)",
929
+ rows: "Key of the row array in the script result (default: auto)",
930
+ retry: "Fresh-tab retries on transient failures (default: 1, max: 5)",
931
+ "retry-delay-ms": "Delay between attempts (default: 500)",
932
+ "allow-empty": "Accept zero rows",
933
+ "keep-tab": "Leave the owned tab open on success and report its id",
934
+ "tab-id": "Extract from an existing tab instead (no fresh tab, no retry; navigates only if a URL is given)",
935
+ session: "Extract from a session's tab instead (same rules as --tab-id)",
936
+ json: "Print {data, rows, rowCount, attempts, readiness} as JSON",
937
+ },
938
+ examples: [
939
+ { cmd: 'extract "https://example.com/list" --file rows.js --ready-selector ".item"', desc: "Fresh tab, wait for items, print a Markdown table" },
940
+ { cmd: 'extract "https://example.com/search?q=x" --file rows.js --options \'{"limit": 20}\' --empty-text "No results" --json', desc: "Options prelude, explicit empty state, JSON output" },
941
+ { cmd: "extract --tab-id 42 --code 'return [...document.querySelectorAll(\"h2\")].map(h => ({ title: h.textContent }))'", desc: "Read an existing tab in place" },
942
+ ]
943
+ },
944
+ }
945
+ },
881
946
  js: {
882
947
  desc: "JavaScript execution",
883
948
  commands: {
884
949
  "js": {
885
950
  desc: "Execute JavaScript (use 'return' for values)",
886
951
  args: ["code"],
887
- opts: { file: "Run JS from file" },
952
+ opts: { file: "Run JS from file", options: "JSON object exposed to the script as a frozen SURF_OPTIONS constant" },
888
953
  examples: [
889
954
  { cmd: 'js "return document.title"', desc: "Get title" },
890
955
  { cmd: 'js "document.body.style.background = \'red\'"', desc: "Run code" },
891
956
  { cmd: "js --file script.js", desc: "Run file" },
957
+ { cmd: 'js --file script.js --options \'{"limit": 20}\'', desc: "Run file with SURF_OPTIONS.limit" },
892
958
  ]
893
959
  },
894
960
  }
@@ -1156,7 +1222,7 @@ const TOOLS = {
1156
1222
  "frame.js": {
1157
1223
  desc: "Execute JS in specific frame",
1158
1224
  args: ["code"],
1159
- opts: { id: "Frame ID from frame.list", file: "Run JS from file" },
1225
+ opts: { id: "Frame ID from frame.list", file: "Run JS from file", options: "JSON object exposed as SURF_OPTIONS" },
1160
1226
  examples: [
1161
1227
  { cmd: 'frame.js "return document.title" --id frame1', desc: "JS in specific frame" },
1162
1228
  ]
@@ -1614,7 +1680,8 @@ const ALL_SOCKET_TOOLS = [
1614
1680
  "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1615
1681
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
1616
1682
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1617
- "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
1683
+ "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load", "wait.ready",
1684
+ "page.readiness",
1618
1685
  "click", "hover", "drag",
1619
1686
  "js", "console", "network",
1620
1687
  "network.get", "network.body", "network.curl", "network.origins",
@@ -1668,7 +1735,9 @@ const SEE_ALSO = {
1668
1735
  "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1669
1736
  "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1670
1737
  "search": ["locate.text", "page.read"],
1671
- "wait.element": ["wait.load", "wait.network"],
1738
+ "wait.element": ["wait.load", "wait.network", "wait.ready"],
1739
+ "wait.ready": ["page.readiness", "wait.element", "wait.url"],
1740
+ "page.readiness": ["wait.ready", "page.state"],
1672
1741
  "wait.load": ["wait.element", "wait.network"],
1673
1742
  "wait.network": ["wait.load", "wait.element"],
1674
1743
  "scroll.to": ["click", "page.read"],
@@ -1722,6 +1791,9 @@ More Help:
1722
1791
  --no-wait Return tab_busy/browser_busy instead of queueing
1723
1792
  --remote <host>:<port> Route requests to a remote native host
1724
1793
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1794
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1795
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1796
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1725
1797
  surf remote authorize <label> --output <path>
1726
1798
  surf remote list | surf remote revoke <label>
1727
1799
  surf --help-full All commands
@@ -1739,6 +1811,7 @@ Purpose: control Chrome from shell. Commands are \`surf <command> [args] [option
1739
1811
  Core loop: navigate -> wait/read -> act -> screenshot/read.
1740
1812
  Navigate: surf navigate "https://example.com" # alias: surf go "..."
1741
1813
  Wait after navigation: surf wait 2 # or wait.load for load complete
1814
+ Wait for real content: surf wait.ready --selector ".results" # fails fast with page_login / page_challenge / page_not_found; --accept login returns the state
1742
1815
  Read DOM/refs: surf page.read --depth 3 --compact # alias: surf read
1743
1816
  Refs: use e1/e2 refs from page.read; prefer refs over CSS when available.
1744
1817
  Click ref: surf click e5
@@ -1751,6 +1824,7 @@ Video recording: surf video start ./demo.webm --fps 30; surf video stop
1751
1824
  Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1752
1825
  Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1753
1826
  JavaScript: surf js "return document.title"
1827
+ Frames: surf frame.list | surf frame.diagnose # diagnose explains why a selector misses inside iframes (shadow roots, srcdoc, out-of-process)
1754
1828
  Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf scroll top
1755
1829
  Find by semantics: surf locate.role button --name "Submit" --action click
1756
1830
  Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
@@ -1793,15 +1867,25 @@ Playbooks:
1793
1867
  Options:
1794
1868
  --remote <host>:<port> Route requests to a remote native host
1795
1869
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1870
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1871
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1872
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1796
1873
  --session <name> Target a durable named session (or set SURF_SESSION)
1797
1874
  --tab-id <id> Target specific tab
1798
1875
  --window-id <id> Target specific window
1799
1876
  --no-wait Return immediately when the tab/browser is busy
1800
1877
  --json Output raw JSON including target metadata
1801
1878
  --auto-capture On error: capture screenshot + console to /tmp
1802
- --soft-fail On error: warn and exit 0 (for non-critical commands)
1879
+ --soft-fail Host tool errors: warn on stderr, exit 0, no JSON error output
1803
1880
  --no-lock Bypass the legacy lock for compound client-side commands
1804
1881
 
1882
+ Host tool-response errors: stderr includes [code] on the first line when supplied;
1883
+ --json also writes {"error":{"code":"...","message":"..."}} to stdout; exit 1.
1884
+ Host details, when present, are included without redundant code/message fields.
1885
+ Missing codes use "error" in JSON. --soft-fail keeps the original warning text.
1886
+ This is not a universal error format: local validation, transport and parser
1887
+ failures keep their existing output/status; --soft-fail does not mask them.
1888
+
1805
1889
  Remote Credentials (run on the browser host):
1806
1890
  surf remote authorize <label> --output <credential-file>
1807
1891
  surf remote list
@@ -2570,6 +2654,150 @@ if (args[0] === "do") {
2570
2654
  return;
2571
2655
  }
2572
2656
 
2657
+ // Handle `surf extract`: a page-side script in an owned tab with a
2658
+ // readiness gate, bounded fresh-tab retry and the zero-rows invariant.
2659
+ if (args[0] === "extract") {
2660
+ const extractArgs = args.slice(1);
2661
+ const valueFlags = new Set([
2662
+ "file", "code", "options", "options-file", "ready-selector", "ready-text", "ready-url-prefix",
2663
+ "ready-timeout", "ready-interval", "empty-text", "rows", "retry", "retry-delay-ms",
2664
+ "tab-id", "session",
2665
+ ]);
2666
+ const boolFlags = new Set(["allow-empty", "keep-tab", "json", "no-wait", "help"]);
2667
+ const opts = {};
2668
+ let url = null;
2669
+ for (let i = 0; i < extractArgs.length; i++) {
2670
+ const arg = extractArgs[i];
2671
+ if (arg === "-f") {
2672
+ opts.file = flagValue(extractArgs, arg);
2673
+ i++;
2674
+ } else if (arg.startsWith("--")) {
2675
+ const key = arg.slice(2);
2676
+ if (boolFlags.has(key)) opts[key] = true;
2677
+ else if (valueFlags.has(key)) {
2678
+ opts[key] = key === "options" && extractArgs[i + 1] === ""
2679
+ ? ""
2680
+ : flagValue(extractArgs, arg);
2681
+ i++;
2682
+ } else {
2683
+ console.error(`Error: unknown extract option --${key}`);
2684
+ process.exit(1);
2685
+ }
2686
+ } else if (url === null) {
2687
+ url = arg;
2688
+ } else {
2689
+ console.error(`Error: unexpected argument ${arg}`);
2690
+ process.exit(1);
2691
+ }
2692
+ }
2693
+ if (opts.help) {
2694
+ showToolHelp("extract");
2695
+ process.exit(0);
2696
+ }
2697
+ const wantJson = opts.json === true;
2698
+ const fail = (code, message, details) => {
2699
+ if (wantJson) {
2700
+ console.log(JSON.stringify({ error: { code, message, ...(details ? { details } : {}) } }, null, 2));
2701
+ } else {
2702
+ console.error(`Error: ${message}${code ? ` [${code}]` : ""}`);
2703
+ }
2704
+ process.exit(1);
2705
+ };
2706
+ if (opts.options !== undefined && opts["options-file"] !== undefined) {
2707
+ fail("usage", "use either --options or --options-file, not both");
2708
+ }
2709
+
2710
+ let code = null;
2711
+ try {
2712
+ if (opts.file && opts.code) fail("usage", "use either --file or --code, not both");
2713
+ if (opts.file) code = fs.readFileSync(opts.file, "utf8");
2714
+ else if (typeof opts.code === "string") code = opts.code;
2715
+ else fail("usage", "an extraction script is required: --file script.js or --code 'return {...}'");
2716
+ } catch (error) {
2717
+ fail("usage", `Failed to read script: ${error.message}`);
2718
+ }
2719
+
2720
+ let scriptOptions = {};
2721
+ try {
2722
+ if (opts["options-file"]) scriptOptions = parseScriptOptions(fs.readFileSync(opts["options-file"], "utf8"));
2723
+ else scriptOptions = parseScriptOptions(opts.options);
2724
+ } catch (error) {
2725
+ fail("usage", error.message);
2726
+ }
2727
+
2728
+ const toInt = (key, fallback) => {
2729
+ if (opts[key] === undefined) return fallback;
2730
+ const parsed = Number(opts[key]);
2731
+ if (!/^\d+$/.test(opts[key]) || !Number.isSafeInteger(parsed)) {
2732
+ fail("usage", `--${key} must be a non-negative integer`);
2733
+ }
2734
+ return parsed;
2735
+ };
2736
+
2737
+ const targetOptions = resolveEarlyTargetOptions(extractArgs, {
2738
+ allowWindow: false,
2739
+ allowEnvironmentSession: false,
2740
+ });
2741
+ const hasTarget = Boolean(targetOptions.tabId || targetOptions.session);
2742
+ if (!hasTarget && !url) fail("usage", "a URL is required unless --tab-id or --session names the page to read");
2743
+
2744
+ const settings = {
2745
+ code,
2746
+ url: url ?? undefined,
2747
+ options: scriptOptions,
2748
+ ready: {
2749
+ selector: opts["ready-selector"],
2750
+ text: opts["ready-text"],
2751
+ urlPrefix: opts["ready-url-prefix"],
2752
+ emptyText: opts["empty-text"],
2753
+ timeout: toInt("ready-timeout", undefined),
2754
+ interval: toInt("ready-interval", undefined),
2755
+ },
2756
+ retry: { count: toInt("retry", undefined), delayMs: toInt("retry-delay-ms", undefined) },
2757
+ keepTab: opts["keep-tab"] === true,
2758
+ allowEmpty: opts["allow-empty"] === true,
2759
+ rowsKey: opts.rows,
2760
+ target: hasTarget,
2761
+ };
2762
+
2763
+ const runExtract = async () => {
2764
+ let transport;
2765
+ try {
2766
+ transport = await openClientTransport(endpoint);
2767
+ const baseContext = { ...targetOptions, endpoint, transport };
2768
+ const executeTool = (toolName, toolArgs, ownedTabId) => {
2769
+ const context = ownedTabId
2770
+ ? { tabId: ownedTabId, admission: targetOptions.admission, endpoint, transport }
2771
+ : baseContext;
2772
+ return sendDoRequest(toolName, toolArgs, context);
2773
+ };
2774
+ const result = await runExtraction({
2775
+ ...settings,
2776
+ executeTool,
2777
+ onEvent: (event) => {
2778
+ if (wantJson) return;
2779
+ if (event.type === "attempt" && event.of > 1) console.error(`[surf] extract attempt ${event.attempt}/${event.of}`);
2780
+ if (event.type === "attempt-failed" && event.retryable) console.error(`[surf] attempt ${event.attempt} failed (${event.error}); retrying with a fresh tab`);
2781
+ },
2782
+ });
2783
+ if (wantJson) {
2784
+ console.log(JSON.stringify(result, null, 2));
2785
+ } else {
2786
+ console.log(renderExtractionMarkdown(result.data, result.rows, { title: url ? `Extraction from ${url}` : "Extraction" }));
2787
+ if (result.tabId) console.error(`[surf] tab ${result.tabId} left open (--keep-tab)`);
2788
+ }
2789
+ return 0;
2790
+ } catch (error) {
2791
+ fail(error.code || "extraction_failed", error.message, error.details);
2792
+ } finally {
2793
+ await transport?.close();
2794
+ }
2795
+ };
2796
+
2797
+ runExtract().then((exitCode) => process.exit(exitCode));
2798
+ return;
2799
+ }
2800
+
2573
2801
  // Handle workflow management commands
2574
2802
  if (args[0] === "workflow.list") {
2575
2803
  const workflows = listWorkflows();
@@ -2981,6 +3209,17 @@ if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2981
3209
  }
2982
3210
  }
2983
3211
 
3212
+ if ((tool === "js" || tool === "frame.js") && toolArgs.options !== undefined) {
3213
+ try {
3214
+ if (typeof toolArgs.code !== "string") throw new Error("--options needs code (inline or --file)");
3215
+ toolArgs.code = applyOptionsPrelude(toolArgs.code, toolArgs.options);
3216
+ delete toolArgs.options;
3217
+ } catch (e) {
3218
+ console.error(`Error: ${e.message}`);
3219
+ process.exit(1);
3220
+ }
3221
+ }
3222
+
2984
3223
  if (tool === "batch" && toolArgs.file) {
2985
3224
  try {
2986
3225
  const parsed = JSON.parse(fs.readFileSync(toolArgs.file, "utf8"));
@@ -3630,7 +3869,25 @@ async function handleResponse(response) {
3630
3869
  socket.end();
3631
3870
  process.exit(0);
3632
3871
  }
3633
- console.error("Error:", errContent);
3872
+ // Host tool-response errors carry codes separately from their display text.
3873
+ const errorCode = typeof response.error.code === "string" ? response.error.code : null;
3874
+ const [firstLine, ...restLines] = errContent.split("\n");
3875
+ const display = errorCode && !firstLine.includes(`[${errorCode}]`)
3876
+ ? [`${firstLine} [${errorCode}]`, ...restLines].join("\n")
3877
+ : errContent;
3878
+ console.error("Error:", display);
3879
+ if (wantJson) {
3880
+ // details repeats code/message when the error serialises itself; keep the rest.
3881
+ const { code: _code, message: _message, ...details } =
3882
+ response.error.details && typeof response.error.details === "object" ? response.error.details : {};
3883
+ console.log(JSON.stringify({
3884
+ error: {
3885
+ code: errorCode || "error",
3886
+ message: typeof response.error.message === "string" ? response.error.message : firstLine,
3887
+ ...(Object.keys(details).length > 0 ? { details } : {}),
3888
+ },
3889
+ }, null, 2));
3890
+ }
3634
3891
 
3635
3892
  if (autoCapture) {
3636
3893
  await performAutoCapture();
@@ -3822,6 +4079,62 @@ async function handleResponse(response) {
3822
4079
  }
3823
4080
  console.log("\nUsage: surf emulate.device \"<device name>\"");
3824
4081
  console.log('Reset: surf emulate.device "reset"');
4082
+ } else if (tool === "wait.ready" || tool === "page.readiness") {
4083
+ const lines = [`state: ${data?.state ?? "unknown"}`];
4084
+ if (data?.href) lines.push(`url: ${data.href}`);
4085
+ if (data?.title) lines.push(`title: ${data.title}`);
4086
+ if (typeof data?.waited === "number") lines.push(`waited: ${data.waited}ms (${data.polls} poll${data.polls === 1 ? "" : "s"})`);
4087
+ if (data?.accepted) lines.push("accepted: negative state returned because of --accept");
4088
+ for (const item of Array.isArray(data?.evidence) ? data.evidence : []) lines.push(`- ${item}`);
4089
+ console.log(lines.join("\n"));
4090
+ } else if (tool === "frame.diagnose" && data?.counts) {
4091
+ // Keep frame URLs readable: embed runners carry kilobyte-long state
4092
+ // parameters that bury the report (use --json for the full URLs).
4093
+ const abbreviateUrl = (url, max = 100) => {
4094
+ if (typeof url !== "string" || url.length <= max) return url;
4095
+ try {
4096
+ const parsed = new URL(url);
4097
+ const base = `${parsed.origin}${parsed.pathname}`;
4098
+ const trailing = url.length - base.length;
4099
+ if (trailing > 0 && base.length <= max - 12) return `${base}?...(+${trailing} chars)`;
4100
+ } catch {}
4101
+ return `${url.slice(0, max - 3)}...`;
4102
+ };
4103
+ const lines = [];
4104
+ lines.push(`Frame diagnosis for ${data.mainPage?.href ?? "?"}${data.mainPage?.title ? ` (${data.mainPage.title})` : ""}`);
4105
+ lines.push(`DOM iframes: ${data.counts.domIframes}, extension frames: ${data.counts.extensionFrames} (incl. main), CDP frames: ${data.counts.cdpFrames}`);
4106
+ if (Array.isArray(data.domIframes) && data.domIframes.length > 0) {
4107
+ lines.push("", "DOM iframes:");
4108
+ for (const f of data.domIframes) {
4109
+ const flags = [
4110
+ f.blank ? "blank" : null,
4111
+ f.crossOrigin ? "cross-origin" : null,
4112
+ f.scriptsBlocked ? "scripts-blocked" : null,
4113
+ f.zeroSize ? "0-size" : null,
4114
+ ].filter(Boolean).join(",");
4115
+ const links = [
4116
+ f.extensionFrameIds?.length ? `ext ${f.extensionFrameIds.join("/")}` : "ext -",
4117
+ f.cdpFrameIds?.length ? `cdp ${f.cdpFrameIds.join("/")}` : "cdp -",
4118
+ ].join(", ");
4119
+ lines.push(` [${f.domIndex}] ${f.srcdoc ? "srcdoc" : abbreviateUrl(f.src || "about:blank")} ${Math.round(f.rect?.width ?? 0)}x${Math.round(f.rect?.height ?? 0)}${f.name ? ` name=${f.name}` : f.id ? ` id=${f.id}` : ""}${f.sandbox !== null && f.sandbox !== undefined ? ` sandbox="${f.sandbox}"` : ""}${f.shadowHost ? ` in shadow root of ${f.shadowHost}` : ""}${flags ? ` [${flags}]` : ""} -> ${links}`);
4120
+ }
4121
+ }
4122
+ if (Array.isArray(data.extensionFrames) && data.extensionFrames.length > 0) {
4123
+ lines.push("", "Extension frames (frame.switch indexes, webNavigation ids):");
4124
+ for (const f of data.extensionFrames) {
4125
+ const reach = f.contentScriptReachable ? "content-script ok" : `content-script unreachable${f.contentScriptError ? ` (${f.contentScriptError})` : ""}`;
4126
+ lines.push(` ${f.isMain ? "main" : `[${f.switchIndex}]`} #${f.frameId}${f.isMain ? "" : ` parent ${f.parentFrameId}`} ${abbreviateUrl(f.url)}${f.crossOrigin ? " [cross-origin]" : ""} - ${reach}`);
4127
+ }
4128
+ }
4129
+ if (Array.isArray(data.cdpFrames) && data.cdpFrames.length > 0) {
4130
+ lines.push("", "CDP frames (frame.js ids):");
4131
+ for (const f of data.cdpFrames) {
4132
+ lines.push(` ${f.frameId}${f.isMain ? " main" : ` parent ${f.parentId}`} ${abbreviateUrl(f.url)}${f.name ? ` name=${f.name}` : ""}${f.extensionFrameIds?.length ? ` -> ext ${f.extensionFrameIds.join("/")}` : ""}`);
4133
+ }
4134
+ }
4135
+ lines.push("", Array.isArray(data.warnings) && data.warnings.length > 0 ? "Warnings:" : "No warnings.");
4136
+ for (const w of Array.isArray(data.warnings) ? data.warnings : []) lines.push(` - ${w}`);
4137
+ console.log(lines.join("\n"));
3825
4138
  } else if (tool === "js") {
3826
4139
  if (data?.result !== undefined) {
3827
4140
  const val = data.result.value ?? data.result;