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/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, semanticRequestContext, 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 } : {}),
@@ -200,6 +202,22 @@ try {
200
202
  process.exit(1);
201
203
  }
202
204
 
205
+ if (args[0] === "semantic" || args[0]?.startsWith("semantic.")) {
206
+ const { formatSemanticOutput, handleSemanticCli } = require("./semantic-cli.cjs");
207
+ handleSemanticCli(args, { endpoint })
208
+ .then((result) => {
209
+ if (!result.handled) throw new Error("Semantic command was not handled");
210
+ if (result.value !== undefined) console.log(formatSemanticOutput(result));
211
+ process.exit(0);
212
+ })
213
+ .catch((error) => {
214
+ const code = error?.code ? ` [${error.code}]` : "";
215
+ console.error(`Error: ${error?.message || String(error)}${code}`);
216
+ process.exit(1);
217
+ });
218
+ return;
219
+ }
220
+
203
221
  if (args[0] === "oracle") {
204
222
  if (args[1] === "ask" || args[1] === "follow") {
205
223
  console.error("[surf] Oracle requires exclusive browser access while dispatching; other sessions will queue.");
@@ -721,6 +739,17 @@ const TOOLS = {
721
739
  examples: [{ cmd: "page.save --output page.html", desc: "Save current document HTML" }],
722
740
  },
723
741
  "page.state": { desc: "Get page state (modals, loading, etc.)", args: [] },
742
+ "page.readiness": {
743
+ desc: "Classify the page once: ready, empty, loading, login, challenge, not-found, error",
744
+ args: [],
745
+ opts: {
746
+ selector: "Visible CSS selector that marks a ready page",
747
+ text: "Page text that marks a ready page",
748
+ "url-prefix": "Expected URL prefix",
749
+ "empty-text": "Text of an explicit no-results render",
750
+ },
751
+ examples: [{ cmd: "page.readiness --json", desc: "State plus evidence as JSON" }]
752
+ },
724
753
  }
725
754
  },
726
755
  locate: {
@@ -824,6 +853,24 @@ const TOOLS = {
824
853
  },
825
854
  "wait.dom": { desc: "Wait for DOM to stabilize", args: [], opts: { stable: "Stability window in ms (default: 100)", timeout: "Max wait time in ms" } },
826
855
  "wait.load": { desc: "Wait for page to fully load", args: [], opts: { timeout: "Max wait time in ms (default: 30000)" } },
856
+ "wait.ready": {
857
+ desc: "Wait until the page is ready, or fail fast with a typed state (challenge, login, not-found, error)",
858
+ args: [],
859
+ opts: {
860
+ selector: "Visible CSS selector that marks a ready page",
861
+ text: "Page text that marks a ready page",
862
+ "url-prefix": "Expected URL prefix; anything else is a bounce",
863
+ "empty-text": "Text of an explicit no-results render (reports state 'empty')",
864
+ accept: "Negative states to return instead of fail (comma list)",
865
+ timeout: "Max wait time in ms (default: 20000, max: 120000)",
866
+ interval: "Poll interval in ms (default: 400)",
867
+ },
868
+ examples: [
869
+ { cmd: 'wait.ready --selector ".results"', desc: "Wait for content; fail fast on a login bounce" },
870
+ { cmd: 'wait.ready --url-prefix "https://app.example.com/" --empty-text "No results"', desc: "Distinguish empty from blocked" },
871
+ { cmd: "wait.ready --accept login --json", desc: "Return the login state to the caller" },
872
+ ]
873
+ },
827
874
  }
828
875
  },
829
876
  input: {
@@ -878,17 +925,52 @@ const TOOLS = {
878
925
  "drag": { desc: "Drag between points", args: [], opts: { from: "Start x,y", to: "End x,y" } },
879
926
  }
880
927
  },
928
+ extract: {
929
+ desc: "Scripted extraction in an owned tab",
930
+ commands: {
931
+ "extract": {
932
+ desc: "Open a URL in a fresh tab, wait until it is ready, run a page-side script that returns JSON, print rows",
933
+ args: ["url"],
934
+ opts: {
935
+ file: "Script file; must `return` JSON (an array, or an object with a rows/items/results array)",
936
+ code: "Inline script instead of --file",
937
+ options: "JSON object exposed to the script as SURF_OPTIONS",
938
+ "options-file": "Read the options object from a JSON file",
939
+ "ready-selector": "wait.ready --selector before extracting",
940
+ "ready-text": "wait.ready --text before extracting",
941
+ "ready-url-prefix": "wait.ready --url-prefix; a different URL is a bounce",
942
+ "empty-text": "wait.ready --empty-text; lets an explicit no-results page pass the zero-rows check",
943
+ "ready-timeout": "Readiness timeout in ms (default: 20000)",
944
+ "ready-interval": "Readiness polling interval in ms (default: 400)",
945
+ rows: "Key of the row array in the script result (default: auto)",
946
+ retry: "Fresh-tab retries on transient failures (default: 1, max: 5)",
947
+ "retry-delay-ms": "Delay between attempts (default: 500)",
948
+ "allow-empty": "Accept zero rows",
949
+ "keep-tab": "Leave the owned tab open on success and report its id",
950
+ "tab-id": "Extract from an existing tab instead (no fresh tab, no retry; navigates only if a URL is given)",
951
+ session: "Extract from a session's tab instead (same rules as --tab-id)",
952
+ json: "Print {data, rows, rowCount, attempts, readiness} as JSON",
953
+ },
954
+ examples: [
955
+ { cmd: 'extract "https://example.com/list" --file rows.js --ready-selector ".item"', desc: "Fresh tab, wait for items, print a Markdown table" },
956
+ { 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" },
957
+ { cmd: "extract --tab-id 42 --code 'return [...document.querySelectorAll(\"h2\")].map(h => ({ title: h.textContent }))'", desc: "Read an existing tab in place" },
958
+ ]
959
+ },
960
+ }
961
+ },
881
962
  js: {
882
963
  desc: "JavaScript execution",
883
964
  commands: {
884
965
  "js": {
885
966
  desc: "Execute JavaScript (use 'return' for values)",
886
967
  args: ["code"],
887
- opts: { file: "Run JS from file" },
968
+ opts: { file: "Run JS from file", options: "JSON object exposed to the script as a frozen SURF_OPTIONS constant" },
888
969
  examples: [
889
970
  { cmd: 'js "return document.title"', desc: "Get title" },
890
971
  { cmd: 'js "document.body.style.background = \'red\'"', desc: "Run code" },
891
972
  { cmd: "js --file script.js", desc: "Run file" },
973
+ { cmd: 'js --file script.js --options \'{"limit": 20}\'', desc: "Run file with SURF_OPTIONS.limit" },
892
974
  ]
893
975
  },
894
976
  }
@@ -1134,6 +1216,15 @@ const TOOLS = {
1134
1216
  args: [],
1135
1217
  examples: [{ cmd: "frame.list", desc: "Show frame tree" }]
1136
1218
  },
1219
+ "frame.diagnose": {
1220
+ desc: "Compare DOM iframes, extension reachability, and the Chrome DevTools frame tree",
1221
+ args: [],
1222
+ opts: {
1223
+ "tab-id": "Target tab ID",
1224
+ json: "Print full frame inventories as JSON"
1225
+ },
1226
+ examples: [{ cmd: "frame.diagnose", desc: "Explain iframe access and count mismatches" }]
1227
+ },
1137
1228
  "frame.switch": {
1138
1229
  desc: "Switch to iframe context",
1139
1230
  args: [],
@@ -1156,7 +1247,7 @@ const TOOLS = {
1156
1247
  "frame.js": {
1157
1248
  desc: "Execute JS in specific frame",
1158
1249
  args: ["code"],
1159
- opts: { id: "Frame ID from frame.list", file: "Run JS from file" },
1250
+ opts: { id: "Frame ID from frame.list", file: "Run JS from file", options: "JSON object exposed as SURF_OPTIONS" },
1160
1251
  examples: [
1161
1252
  { cmd: 'frame.js "return document.title" --id frame1', desc: "JS in specific frame" },
1162
1253
  ]
@@ -1242,7 +1333,10 @@ const TOOLS = {
1242
1333
  "on-error": "stop (default) | continue",
1243
1334
  "no-auto-wait": "Disable automatic waits between steps",
1244
1335
  "step-delay": "Delay between steps in ms (default: 100)",
1245
- "dry-run": "Parse and validate without executing"
1336
+ "dry-run": "Parse and validate without executing",
1337
+ "allow-semantic": "Allow bounded TypeSafe semantic decisions",
1338
+ "allow-write": "Allow declared semantic fill/check/click steps",
1339
+ "inputs-stdin": "Read bounded private input slots as one JSON object from stdin",
1246
1340
  },
1247
1341
  examples: [
1248
1342
  { cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
@@ -1518,8 +1612,19 @@ Tips:
1518
1612
  - Use window.new --incognito for isolated cookies`
1519
1613
  },
1520
1614
  semantic: {
1521
- title: "Semantic Locators",
1522
- content: `Find elements by role, text, or label instead of refs or selectors.
1615
+ title: "Semantic browser decisions and locators",
1616
+ content: `Optional Jev commands (page-derived text is sent to TypeSafe only for these commands):
1617
+ semantic.find "the notification control"
1618
+ semantic.verify "Notification preferences were saved"
1619
+ semantic.filter "notification preferences"
1620
+ semantic.act "Open notification settings" --max-steps 5
1621
+ semantic.act "Fill email" --input email="$EMAIL" --allow-write
1622
+ semantic auth set|status|clear
1623
+
1624
+ Every click/fill requires --allow-write. This broadly authorizes even high-impact controls;
1625
+ repeat --allow-ref <ref> to narrow authorization to exact observed refs.
1626
+
1627
+ Local semantic locators find elements by role, text, or label instead of refs or selectors.
1523
1628
 
1524
1629
  By ARIA role:
1525
1630
  locate.role button --name "Submit" --action click
@@ -1614,7 +1719,8 @@ const ALL_SOCKET_TOOLS = [
1614
1719
  "tab.list", "tab.new", "tab.switch", "tab.close", "tab.move", "tab.name", "tab.unname", "tab.named",
1615
1720
  "tab.group", "tab.ungroup", "tab.groups", "tab.reload",
1616
1721
  "scroll.top", "scroll.bottom", "scroll.to", "scroll.info",
1617
- "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load",
1722
+ "wait.element", "wait.network", "wait.url", "wait.dom", "wait.load", "wait.ready",
1723
+ "page.readiness",
1618
1724
  "click", "hover", "drag",
1619
1725
  "js", "console", "network",
1620
1726
  "network.get", "network.body", "network.curl", "network.origins",
@@ -1624,7 +1730,7 @@ const ALL_SOCKET_TOOLS = [
1624
1730
  "form.fill",
1625
1731
  "perf.start", "perf.stop", "perf.metrics",
1626
1732
  "upload",
1627
- "frame.list", "frame.switch", "frame.main", "frame.js",
1733
+ "frame.list", "frame.diagnose", "frame.switch", "frame.main", "frame.js",
1628
1734
  "cookie.list", "cookie.get", "cookie.set", "cookie.clear",
1629
1735
  "search", "batch",
1630
1736
  "zoom", "resize",
@@ -1647,6 +1753,7 @@ const SEE_ALSO = {
1647
1753
  "window.new": ["window.list"],
1648
1754
  "window.list": ["tab.list"],
1649
1755
  "frame.list": ["frame.switch", "frame.main"],
1756
+ "frame.diagnose": ["frame.list", "frame.switch", "frame.js"],
1650
1757
  "frame.switch": ["frame.list", "frame.main", "frame.js"],
1651
1758
  "frame.main": ["frame.list", "frame.switch"],
1652
1759
  "frame.js": ["frame.switch", "js"],
@@ -1668,7 +1775,9 @@ const SEE_ALSO = {
1668
1775
  "animate-audit": ["screenshot", "record", "perf-audit", "js"],
1669
1776
  "perf-audit": ["record", "animate-audit", "perf.metrics", "console"],
1670
1777
  "search": ["locate.text", "page.read"],
1671
- "wait.element": ["wait.load", "wait.network"],
1778
+ "wait.element": ["wait.load", "wait.network", "wait.ready"],
1779
+ "wait.ready": ["page.readiness", "wait.element", "wait.url"],
1780
+ "page.readiness": ["wait.ready", "page.state"],
1672
1781
  "wait.load": ["wait.element", "wait.network"],
1673
1782
  "wait.network": ["wait.load", "wait.element"],
1674
1783
  "scroll.to": ["click", "page.read"],
@@ -1698,6 +1807,8 @@ Common Commands:
1698
1807
  animate-audit JSON timeline of element animation/style samples
1699
1808
  perf-audit PerformanceObserver snapshot for motion/jank debugging
1700
1809
  page.read Get page accessibility tree (alias: read)
1810
+ semantic.find Optional Jev-powered candidate selection
1811
+ semantic.act Bounded semantic browser action controller
1701
1812
  locate.role <role> Find element by ARIA role
1702
1813
  search <term> Search for text in page (alias: find)
1703
1814
  window.new <url> Create isolated browser window
@@ -1722,6 +1833,9 @@ More Help:
1722
1833
  --no-wait Return tab_busy/browser_busy instead of queueing
1723
1834
  --remote <host>:<port> Route requests to a remote native host
1724
1835
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1836
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1837
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1838
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1725
1839
  surf remote authorize <label> --output <path>
1726
1840
  surf remote list | surf remote revoke <label>
1727
1841
  surf --help-full All commands
@@ -1739,6 +1853,7 @@ Purpose: control Chrome from shell. Commands are \`surf <command> [args] [option
1739
1853
  Core loop: navigate -> wait/read -> act -> screenshot/read.
1740
1854
  Navigate: surf navigate "https://example.com" # alias: surf go "..."
1741
1855
  Wait after navigation: surf wait 2 # or wait.load for load complete
1856
+ 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
1857
  Read DOM/refs: surf page.read --depth 3 --compact # alias: surf read
1743
1858
  Refs: use e1/e2 refs from page.read; prefer refs over CSS when available.
1744
1859
  Click ref: surf click e5
@@ -1751,6 +1866,7 @@ Video recording: surf video start ./demo.webm --fps 30; surf video stop
1751
1866
  Animation audit: surf animate-audit --selector ".thing" --duration 2000 --fps 10
1752
1867
  Performance audit: surf perf-audit --duration 3000 --trigger "click:.cta" --output /tmp/perf.json
1753
1868
  JavaScript: surf js "return document.title"
1869
+ Frames: surf frame.list | surf frame.diagnose # diagnose explains why a selector misses inside iframes (shadow roots, srcdoc, out-of-process)
1754
1870
  Scroll: surf scroll down 800 | surf scroll up 400 | surf scroll bottom | surf scroll top
1755
1871
  Find by semantics: surf locate.role button --name "Submit" --action click
1756
1872
  Device/viewport: surf emulate.device "iPhone 14" | surf resize 375 812
@@ -1770,6 +1886,11 @@ const showFullHelp = () => {
1770
1886
 
1771
1887
  Usage: surf <command> [args] [options]
1772
1888
 
1889
+ Semantic (optional TypeSafe/Jev):
1890
+ surf semantic.find|verify|filter <goal> [--json]
1891
+ surf semantic.act <goal> [--allow-write] [--allow-ref <ref>] [--input <name=value>]
1892
+ surf semantic auth <set|status|clear>
1893
+
1773
1894
  Oracle:
1774
1895
  surf oracle <ask|status|result|follow|list>
1775
1896
 
@@ -1793,15 +1914,25 @@ Playbooks:
1793
1914
  Options:
1794
1915
  --remote <host>:<port> Route requests to a remote native host
1795
1916
  --remote-credential <path> Use a mode-0600 Ed25519 remote credential file
1917
+ --remote-tls Use TLS through a TLS-terminating reverse proxy
1918
+ --remote-tls-ca <path> Replace system roots with a custom CA bundle
1919
+ --remote-tls-server-name <name> Override TLS SNI and certificate identity
1796
1920
  --session <name> Target a durable named session (or set SURF_SESSION)
1797
1921
  --tab-id <id> Target specific tab
1798
1922
  --window-id <id> Target specific window
1799
1923
  --no-wait Return immediately when the tab/browser is busy
1800
1924
  --json Output raw JSON including target metadata
1801
1925
  --auto-capture On error: capture screenshot + console to /tmp
1802
- --soft-fail On error: warn and exit 0 (for non-critical commands)
1926
+ --soft-fail Host tool errors: warn on stderr, exit 0, no JSON error output
1803
1927
  --no-lock Bypass the legacy lock for compound client-side commands
1804
1928
 
1929
+ Host tool-response errors: stderr includes [code] on the first line when supplied;
1930
+ --json also writes {"error":{"code":"...","message":"..."}} to stdout; exit 1.
1931
+ Host details, when present, are included without redundant code/message fields.
1932
+ Missing codes use "error" in JSON. --soft-fail keeps the original warning text.
1933
+ This is not a universal error format: local validation, transport and parser
1934
+ failures keep their existing output/status; --soft-fail does not mask them.
1935
+
1805
1936
  Remote Credentials (run on the browser host):
1806
1937
  surf remote authorize <label> --output <credential-file>
1807
1938
  surf remote list
@@ -2323,9 +2454,12 @@ if (args[0] === "do") {
2323
2454
  let windowId = undefined;
2324
2455
  let explicitSession = undefined;
2325
2456
  let noWait = false;
2457
+ let allowSemantic = false;
2458
+ let allowWrite = false;
2459
+ let inputsStdin = false;
2326
2460
 
2327
2461
  // Reserved flags that aren't workflow args
2328
- const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'session', 'no-lock', 'no-wait'];
2462
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'session', 'no-lock', 'no-wait', 'allow-semantic', 'allow-write', 'inputs-stdin'];
2329
2463
 
2330
2464
  // Workflow-specific args (collected for variable substitution)
2331
2465
  const workflowArgs = {};
@@ -2365,6 +2499,12 @@ if (args[0] === "do") {
2365
2499
  i++;
2366
2500
  } else if (arg === "--no-wait") {
2367
2501
  noWait = true;
2502
+ } else if (arg === "--allow-semantic") {
2503
+ allowSemantic = true;
2504
+ } else if (arg === "--allow-write") {
2505
+ allowWrite = true;
2506
+ } else if (arg === "--inputs-stdin") {
2507
+ inputsStdin = true;
2368
2508
  } else if (arg.startsWith("--")) {
2369
2509
  // Workflow-specific arg (e.g., --email, --password)
2370
2510
  const key = arg.slice(2);
@@ -2489,6 +2629,27 @@ if (args[0] === "do") {
2489
2629
 
2490
2630
  // Apply arg defaults
2491
2631
  const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
2632
+ let privateInputs = {};
2633
+ if (inputsStdin) {
2634
+ try {
2635
+ const { SEMANTIC_POLICY } = require("./semantic-core.cjs");
2636
+ const input = fs.readFileSync(0, "utf8");
2637
+ if (Buffer.byteLength(input, "utf8") > 262144) throw new Error("input JSON exceeds 256 KiB");
2638
+ privateInputs = JSON.parse(input);
2639
+ if (!privateInputs || typeof privateInputs !== "object" || Array.isArray(privateInputs)) throw new Error("input JSON must be an object");
2640
+ const entries = Object.entries(privateInputs);
2641
+ if (entries.length > SEMANTIC_POLICY.limits.inputSlots) throw new Error(`input JSON supports at most ${SEMANTIC_POLICY.limits.inputSlots} slots`);
2642
+ for (const [name, value] of entries) {
2643
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error("input JSON contains an invalid slot name");
2644
+ if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`input slot '${name}' must be a string, number, or boolean`);
2645
+ if (Buffer.byteLength(String(value), "utf8") > SEMANTIC_POLICY.limits.inputValueBytes) throw new Error(`input slot '${name}' exceeds ${SEMANTIC_POLICY.limits.inputValueBytes / 1024} KiB`);
2646
+ if (Object.hasOwn(vars, name)) throw new Error(`input slot '${name}' was supplied more than once`);
2647
+ }
2648
+ } catch (error) {
2649
+ console.error(`Error: Invalid --inputs-stdin JSON: ${error.message}`);
2650
+ process.exit(1);
2651
+ }
2652
+ }
2492
2653
 
2493
2654
  // Validate with --dry-run
2494
2655
  if (dryRun) {
@@ -2509,6 +2670,21 @@ if (args[0] === "do") {
2509
2670
  process.exit(0);
2510
2671
  }
2511
2672
 
2673
+ const semanticEnabled = Boolean(workflow?.semantic);
2674
+ if (semanticEnabled && !allowSemantic) {
2675
+ console.error("Error: semantic workflows require --allow-semantic");
2676
+ process.exit(1);
2677
+ }
2678
+ if (semanticEnabled && onError === "continue") {
2679
+ console.error("Error: semantic workflows do not support --on-error continue");
2680
+ process.exit(1);
2681
+ }
2682
+ const writeOps = new Set(["ensureChecked", "fill", "click"]);
2683
+ if (semanticEnabled && steps.some((step) => writeOps.has(step.args?.op)) && !allowWrite) {
2684
+ console.error("Error: mutation-capable semantic steps require --allow-write");
2685
+ process.exit(1);
2686
+ }
2687
+
2512
2688
  if (!wantJson) {
2513
2689
  if (workflowName) {
2514
2690
  console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
@@ -2536,6 +2712,17 @@ if (args[0] === "do") {
2536
2712
  endpoint,
2537
2713
  transport,
2538
2714
  },
2715
+ ...(semanticEnabled ? {
2716
+ createSemanticExecutor: async ({ context }) => {
2717
+ const { createConcreteSemanticExecutor } = require("./semantic-workflow-executor.cjs");
2718
+ return createConcreteSemanticExecutor({
2719
+ workflow,
2720
+ inputs: { ...vars, ...privateInputs },
2721
+ request: (tool, toolArgs, timeoutMs, identity) =>
2722
+ sendDoRequest(tool, toolArgs, semanticRequestContext(context, timeoutMs, identity)),
2723
+ });
2724
+ },
2725
+ } : {}),
2539
2726
  });
2540
2727
 
2541
2728
  // Print summary
@@ -2570,6 +2757,150 @@ if (args[0] === "do") {
2570
2757
  return;
2571
2758
  }
2572
2759
 
2760
+ // Handle `surf extract`: a page-side script in an owned tab with a
2761
+ // readiness gate, bounded fresh-tab retry and the zero-rows invariant.
2762
+ if (args[0] === "extract") {
2763
+ const extractArgs = args.slice(1);
2764
+ const valueFlags = new Set([
2765
+ "file", "code", "options", "options-file", "ready-selector", "ready-text", "ready-url-prefix",
2766
+ "ready-timeout", "ready-interval", "empty-text", "rows", "retry", "retry-delay-ms",
2767
+ "tab-id", "session",
2768
+ ]);
2769
+ const boolFlags = new Set(["allow-empty", "keep-tab", "json", "no-wait", "help"]);
2770
+ const opts = {};
2771
+ let url = null;
2772
+ for (let i = 0; i < extractArgs.length; i++) {
2773
+ const arg = extractArgs[i];
2774
+ if (arg === "-f") {
2775
+ opts.file = flagValue(extractArgs, arg);
2776
+ i++;
2777
+ } else if (arg.startsWith("--")) {
2778
+ const key = arg.slice(2);
2779
+ if (boolFlags.has(key)) opts[key] = true;
2780
+ else if (valueFlags.has(key)) {
2781
+ opts[key] = key === "options" && extractArgs[i + 1] === ""
2782
+ ? ""
2783
+ : flagValue(extractArgs, arg);
2784
+ i++;
2785
+ } else {
2786
+ console.error(`Error: unknown extract option --${key}`);
2787
+ process.exit(1);
2788
+ }
2789
+ } else if (url === null) {
2790
+ url = arg;
2791
+ } else {
2792
+ console.error(`Error: unexpected argument ${arg}`);
2793
+ process.exit(1);
2794
+ }
2795
+ }
2796
+ if (opts.help) {
2797
+ showToolHelp("extract");
2798
+ process.exit(0);
2799
+ }
2800
+ const wantJson = opts.json === true;
2801
+ const fail = (code, message, details) => {
2802
+ if (wantJson) {
2803
+ console.log(JSON.stringify({ error: { code, message, ...(details ? { details } : {}) } }, null, 2));
2804
+ } else {
2805
+ console.error(`Error: ${message}${code ? ` [${code}]` : ""}`);
2806
+ }
2807
+ process.exit(1);
2808
+ };
2809
+ if (opts.options !== undefined && opts["options-file"] !== undefined) {
2810
+ fail("usage", "use either --options or --options-file, not both");
2811
+ }
2812
+
2813
+ let code = null;
2814
+ try {
2815
+ if (opts.file && opts.code) fail("usage", "use either --file or --code, not both");
2816
+ if (opts.file) code = fs.readFileSync(opts.file, "utf8");
2817
+ else if (typeof opts.code === "string") code = opts.code;
2818
+ else fail("usage", "an extraction script is required: --file script.js or --code 'return {...}'");
2819
+ } catch (error) {
2820
+ fail("usage", `Failed to read script: ${error.message}`);
2821
+ }
2822
+
2823
+ let scriptOptions = {};
2824
+ try {
2825
+ if (opts["options-file"]) scriptOptions = parseScriptOptions(fs.readFileSync(opts["options-file"], "utf8"));
2826
+ else scriptOptions = parseScriptOptions(opts.options);
2827
+ } catch (error) {
2828
+ fail("usage", error.message);
2829
+ }
2830
+
2831
+ const toInt = (key, fallback) => {
2832
+ if (opts[key] === undefined) return fallback;
2833
+ const parsed = Number(opts[key]);
2834
+ if (!/^\d+$/.test(opts[key]) || !Number.isSafeInteger(parsed)) {
2835
+ fail("usage", `--${key} must be a non-negative integer`);
2836
+ }
2837
+ return parsed;
2838
+ };
2839
+
2840
+ const targetOptions = resolveEarlyTargetOptions(extractArgs, {
2841
+ allowWindow: false,
2842
+ allowEnvironmentSession: false,
2843
+ });
2844
+ const hasTarget = Boolean(targetOptions.tabId || targetOptions.session);
2845
+ if (!hasTarget && !url) fail("usage", "a URL is required unless --tab-id or --session names the page to read");
2846
+
2847
+ const settings = {
2848
+ code,
2849
+ url: url ?? undefined,
2850
+ options: scriptOptions,
2851
+ ready: {
2852
+ selector: opts["ready-selector"],
2853
+ text: opts["ready-text"],
2854
+ urlPrefix: opts["ready-url-prefix"],
2855
+ emptyText: opts["empty-text"],
2856
+ timeout: toInt("ready-timeout", undefined),
2857
+ interval: toInt("ready-interval", undefined),
2858
+ },
2859
+ retry: { count: toInt("retry", undefined), delayMs: toInt("retry-delay-ms", undefined) },
2860
+ keepTab: opts["keep-tab"] === true,
2861
+ allowEmpty: opts["allow-empty"] === true,
2862
+ rowsKey: opts.rows,
2863
+ target: hasTarget,
2864
+ };
2865
+
2866
+ const runExtract = async () => {
2867
+ let transport;
2868
+ try {
2869
+ transport = await openClientTransport(endpoint);
2870
+ const baseContext = { ...targetOptions, endpoint, transport };
2871
+ const executeTool = (toolName, toolArgs, ownedTabId) => {
2872
+ const context = ownedTabId
2873
+ ? { tabId: ownedTabId, admission: targetOptions.admission, endpoint, transport }
2874
+ : baseContext;
2875
+ return sendDoRequest(toolName, toolArgs, context);
2876
+ };
2877
+ const result = await runExtraction({
2878
+ ...settings,
2879
+ executeTool,
2880
+ onEvent: (event) => {
2881
+ if (wantJson) return;
2882
+ if (event.type === "attempt" && event.of > 1) console.error(`[surf] extract attempt ${event.attempt}/${event.of}`);
2883
+ if (event.type === "attempt-failed" && event.retryable) console.error(`[surf] attempt ${event.attempt} failed (${event.error}); retrying with a fresh tab`);
2884
+ },
2885
+ });
2886
+ if (wantJson) {
2887
+ console.log(JSON.stringify(result, null, 2));
2888
+ } else {
2889
+ console.log(renderExtractionMarkdown(result.data, result.rows, { title: url ? `Extraction from ${url}` : "Extraction" }));
2890
+ if (result.tabId) console.error(`[surf] tab ${result.tabId} left open (--keep-tab)`);
2891
+ }
2892
+ return 0;
2893
+ } catch (error) {
2894
+ fail(error.code || "extraction_failed", error.message, error.details);
2895
+ } finally {
2896
+ await transport?.close();
2897
+ }
2898
+ };
2899
+
2900
+ runExtract().then((exitCode) => process.exit(exitCode));
2901
+ return;
2902
+ }
2903
+
2573
2904
  // Handle workflow management commands
2574
2905
  if (args[0] === "workflow.list") {
2575
2906
  const workflows = listWorkflows();
@@ -2981,6 +3312,17 @@ if ((tool === "js" || tool === "frame.js") && toolArgs.file) {
2981
3312
  }
2982
3313
  }
2983
3314
 
3315
+ if ((tool === "js" || tool === "frame.js") && toolArgs.options !== undefined) {
3316
+ try {
3317
+ if (typeof toolArgs.code !== "string") throw new Error("--options needs code (inline or --file)");
3318
+ toolArgs.code = applyOptionsPrelude(toolArgs.code, toolArgs.options);
3319
+ delete toolArgs.options;
3320
+ } catch (e) {
3321
+ console.error(`Error: ${e.message}`);
3322
+ process.exit(1);
3323
+ }
3324
+ }
3325
+
2984
3326
  if (tool === "batch" && toolArgs.file) {
2985
3327
  try {
2986
3328
  const parsed = JSON.parse(fs.readFileSync(toolArgs.file, "utf8"));
@@ -3630,7 +3972,25 @@ async function handleResponse(response) {
3630
3972
  socket.end();
3631
3973
  process.exit(0);
3632
3974
  }
3633
- console.error("Error:", errContent);
3975
+ // Host tool-response errors carry codes separately from their display text.
3976
+ const errorCode = typeof response.error.code === "string" ? response.error.code : null;
3977
+ const [firstLine, ...restLines] = errContent.split("\n");
3978
+ const display = errorCode && !firstLine.includes(`[${errorCode}]`)
3979
+ ? [`${firstLine} [${errorCode}]`, ...restLines].join("\n")
3980
+ : errContent;
3981
+ console.error("Error:", display);
3982
+ if (wantJson) {
3983
+ // details repeats code/message when the error serialises itself; keep the rest.
3984
+ const { code: _code, message: _message, ...details } =
3985
+ response.error.details && typeof response.error.details === "object" ? response.error.details : {};
3986
+ console.log(JSON.stringify({
3987
+ error: {
3988
+ code: errorCode || "error",
3989
+ message: typeof response.error.message === "string" ? response.error.message : firstLine,
3990
+ ...(Object.keys(details).length > 0 ? { details } : {}),
3991
+ },
3992
+ }, null, 2));
3993
+ }
3634
3994
 
3635
3995
  if (autoCapture) {
3636
3996
  await performAutoCapture();
@@ -3822,6 +4182,62 @@ async function handleResponse(response) {
3822
4182
  }
3823
4183
  console.log("\nUsage: surf emulate.device \"<device name>\"");
3824
4184
  console.log('Reset: surf emulate.device "reset"');
4185
+ } else if (tool === "wait.ready" || tool === "page.readiness") {
4186
+ const lines = [`state: ${data?.state ?? "unknown"}`];
4187
+ if (data?.href) lines.push(`url: ${data.href}`);
4188
+ if (data?.title) lines.push(`title: ${data.title}`);
4189
+ if (typeof data?.waited === "number") lines.push(`waited: ${data.waited}ms (${data.polls} poll${data.polls === 1 ? "" : "s"})`);
4190
+ if (data?.accepted) lines.push("accepted: negative state returned because of --accept");
4191
+ for (const item of Array.isArray(data?.evidence) ? data.evidence : []) lines.push(`- ${item}`);
4192
+ console.log(lines.join("\n"));
4193
+ } else if (tool === "frame.diagnose" && data?.counts) {
4194
+ // Keep frame URLs readable: embed runners carry kilobyte-long state
4195
+ // parameters that bury the report (use --json for the full URLs).
4196
+ const abbreviateUrl = (url, max = 100) => {
4197
+ if (typeof url !== "string" || url.length <= max) return url;
4198
+ try {
4199
+ const parsed = new URL(url);
4200
+ const base = `${parsed.origin}${parsed.pathname}`;
4201
+ const trailing = url.length - base.length;
4202
+ if (trailing > 0 && base.length <= max - 12) return `${base}?...(+${trailing} chars)`;
4203
+ } catch {}
4204
+ return `${url.slice(0, max - 3)}...`;
4205
+ };
4206
+ const lines = [];
4207
+ lines.push(`Frame diagnosis for ${data.mainPage?.href ?? "?"}${data.mainPage?.title ? ` (${data.mainPage.title})` : ""}`);
4208
+ lines.push(`DOM iframes: ${data.counts.domIframes}, extension frames: ${data.counts.extensionFrames} (incl. main), CDP frames: ${data.counts.cdpFrames}`);
4209
+ if (Array.isArray(data.domIframes) && data.domIframes.length > 0) {
4210
+ lines.push("", "DOM iframes:");
4211
+ for (const f of data.domIframes) {
4212
+ const flags = [
4213
+ f.blank ? "blank" : null,
4214
+ f.crossOrigin ? "cross-origin" : null,
4215
+ f.scriptsBlocked ? "scripts-blocked" : null,
4216
+ f.zeroSize ? "0-size" : null,
4217
+ ].filter(Boolean).join(",");
4218
+ const links = [
4219
+ f.extensionFrameIds?.length ? `ext ${f.extensionFrameIds.join("/")}` : "ext -",
4220
+ f.cdpFrameIds?.length ? `cdp ${f.cdpFrameIds.join("/")}` : "cdp -",
4221
+ ].join(", ");
4222
+ 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}`);
4223
+ }
4224
+ }
4225
+ if (Array.isArray(data.extensionFrames) && data.extensionFrames.length > 0) {
4226
+ lines.push("", "Extension frames (frame.switch indexes, webNavigation ids):");
4227
+ for (const f of data.extensionFrames) {
4228
+ const reach = f.contentScriptReachable ? "content-script ok" : `content-script unreachable${f.contentScriptError ? ` (${f.contentScriptError})` : ""}`;
4229
+ lines.push(` ${f.isMain ? "main" : `[${f.switchIndex}]`} #${f.frameId}${f.isMain ? "" : ` parent ${f.parentFrameId}`} ${abbreviateUrl(f.url)}${f.crossOrigin ? " [cross-origin]" : ""} - ${reach}`);
4230
+ }
4231
+ }
4232
+ if (Array.isArray(data.cdpFrames) && data.cdpFrames.length > 0) {
4233
+ lines.push("", "CDP frames (frame.js ids):");
4234
+ for (const f of data.cdpFrames) {
4235
+ 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("/")}` : ""}`);
4236
+ }
4237
+ }
4238
+ lines.push("", Array.isArray(data.warnings) && data.warnings.length > 0 ? "Warnings:" : "No warnings.");
4239
+ for (const w of Array.isArray(data.warnings) ? data.warnings : []) lines.push(` - ${w}`);
4240
+ console.log(lines.join("\n"));
3825
4241
  } else if (tool === "js") {
3826
4242
  if (data?.result !== undefined) {
3827
4243
  const val = data.result.value ?? data.result;