surf-cli 2.19.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.
@@ -228,6 +228,21 @@ function formatToolContent(result, log = () => {}, options = {}) {
228
228
  ];
229
229
  }
230
230
 
231
+ if (result.semanticObservation !== undefined) {
232
+ return text(JSON.stringify({
233
+ pageContent: result.pageContent,
234
+ viewport: result.viewport,
235
+ semanticObservation: result.semanticObservation,
236
+ }));
237
+ }
238
+
239
+ if (
240
+ result.identity !== undefined && result.matches !== undefined ||
241
+ result.scopeToken !== undefined && result.geometry !== undefined
242
+ ) {
243
+ return text(JSON.stringify(result));
244
+ }
245
+
231
246
  if (result.pageContent !== undefined) {
232
247
  const content = result.pageContent || "No content";
233
248
  let output = '';
@@ -442,6 +457,11 @@ function formatToolContent(result, log = () => {}, options = {}) {
442
457
 
443
458
  // Bug fix: Handle success with metrics/frames/readyState/hint in one block
444
459
  if (result.success) {
460
+ // Keep the response envelope intact so the CLI can print body bytes verbatim
461
+ // for text output while still producing structured output under --json.
462
+ if (typeof result.body === "string" && Object.hasOwn(result, "base64Encoded")) {
463
+ return text(JSON.stringify(result));
464
+ }
445
465
  if (result.metrics) {
446
466
  return text(JSON.stringify(result.metrics, null, 2));
447
467
  }
@@ -501,7 +521,7 @@ function mapComputerAction(args, tabId) {
501
521
  const { action, text, scroll_direction, scroll_amount,
502
522
  start_coordinate, ref, duration, modifiers } = a;
503
523
  const coordinate = a.coordinate || (a.x !== undefined && a.y !== undefined ? [a.x, a.y] : undefined);
504
- const baseMsg = { tabId };
524
+ const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
505
525
 
506
526
  if (!action) {
507
527
  return { type: "UNSUPPORTED_ACTION", action: null, message: "No action specified for computer tool" };
@@ -512,7 +532,7 @@ function mapComputerAction(args, tabId) {
512
532
  return { type: "EXECUTE_SCREENSHOT", ...baseMsg };
513
533
 
514
534
  case "left_click":
515
- if (ref) return { type: "CLICK_REF", ref, button: "left", ...baseMsg };
535
+ if (ref) return { type: "CLICK_REF", ref, button: "left", expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
516
536
  if (a.selector) return { type: "CLICK_SELECTOR", selector: a.selector, index: a.index || 0, button: "left", ...baseMsg };
517
537
  return { type: "EXECUTE_CLICK", x: coordinate?.[0], y: coordinate?.[1], modifiers, ...baseMsg };
518
538
 
@@ -530,7 +550,7 @@ function mapComputerAction(args, tabId) {
530
550
 
531
551
  case "type": {
532
552
  if (ref) {
533
- return { type: "FORM_FILL", data: [{ ref, value: text }], ...baseMsg };
553
+ return { type: "FORM_FILL", data: [{ ref, value: text }], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
534
554
  }
535
555
  const typeSelector = a.selector || a.into;
536
556
  if (typeSelector) {
@@ -570,7 +590,7 @@ function mapComputerAction(args, tabId) {
570
590
  right: { deltaX: amount, deltaY: 0 },
571
591
  };
572
592
  const { deltaX, deltaY } = deltas[direction] || { deltaX: 0, deltaY: 0 };
573
- return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], ...baseMsg };
593
+ return { type: "EXECUTE_SCROLL", deltaX, deltaY, x: coordinate?.[0], y: coordinate?.[1], expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
574
594
  }
575
595
 
576
596
  case "scroll_to":
@@ -609,14 +629,14 @@ function mapComputerAction(args, tabId) {
609
629
  * Map tool name and args to extension message
610
630
  */
611
631
  function mapToolToMessage(tool, args, tabId) {
612
- const baseMsg = { tabId };
613
632
  const a = args || {};
633
+ const baseMsg = { tabId, ...(Number.isInteger(a.semanticFrameId) ? { frameId: a.semanticFrameId } : {}) };
614
634
 
615
635
  switch (tool) {
616
636
  case "computer":
617
637
  return mapComputerAction(args, tabId);
618
638
  case "navigate":
619
- return { type: "EXECUTE_NAVIGATE", url: a.url, ...baseMsg };
639
+ return { type: "EXECUTE_NAVIGATE", url: a.url, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
620
640
  case "read_page":
621
641
  return {
622
642
  type: "READ_PAGE",
@@ -880,9 +900,9 @@ function mapToolToMessage(tool, args, tabId) {
880
900
  case "js":
881
901
  return { type: "EXECUTE_JAVASCRIPT", code: a.code, ...baseMsg };
882
902
  case "scroll.top":
883
- return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, ...baseMsg };
903
+ return { type: "SCROLL_TO_POSITION", position: "top", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
884
904
  case "scroll.bottom":
885
- return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, ...baseMsg };
905
+ return { type: "SCROLL_TO_POSITION", position: "bottom", selector: a.selector, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
886
906
  case "scroll.info":
887
907
  return { type: "GET_SCROLL_INFO", selector: a.selector, ...baseMsg };
888
908
  case "scroll.to":
@@ -966,7 +986,7 @@ function mapToolToMessage(tool, args, tabId) {
966
986
  if (typeof fillData === "string") {
967
987
  try { fillData = JSON.parse(fillData); } catch (e) { throw new Error("invalid --data JSON"); }
968
988
  }
969
- return { type: "FORM_FILL", data: fillData, ...baseMsg };
989
+ return { type: "FORM_FILL", data: fillData, expectedIdentity: a.semanticExpectedIdentity, ...baseMsg };
970
990
  case "perf.start":
971
991
  return { type: "PERF_START", categories: a.categories ? a.categories.split(",") : undefined, ...baseMsg };
972
992
  case "perf.stop":
@@ -998,10 +1018,30 @@ function mapToolToMessage(tool, args, tabId) {
998
1018
  compact: a.compact || false,
999
1019
  maxBytes,
1000
1020
  forceFullSnapshot: a.compact === true || maxBytes !== undefined,
1021
+ ...(a.semanticObservation === true ? { semanticObservation: true } : {}),
1001
1022
  },
1002
1023
  ...baseMsg
1003
1024
  };
1004
1025
  }
1026
+ case "semantic.localCompare":
1027
+ return {
1028
+ type: "SEMANTIC_LOCAL_COMPARE",
1029
+ ref: a.ref,
1030
+ predicate: a.predicate,
1031
+ expectedIdentity: a.semanticExpectedIdentity,
1032
+ ...baseMsg,
1033
+ };
1034
+ case "semantic.scrollScope":
1035
+ if (!["inspect", "top", "advance"].includes(a.action)) {
1036
+ throw new Error("semantic scroll scope action must be inspect, top, or advance");
1037
+ }
1038
+ return {
1039
+ type: "SEMANTIC_SCROLL_SCOPE",
1040
+ action: a.action,
1041
+ scopeToken: a.scopeToken,
1042
+ expectedIdentity: a.semanticExpectedIdentity,
1043
+ ...baseMsg,
1044
+ };
1005
1045
  case "page.text":
1006
1046
  return { type: "GET_PAGE_TEXT", ...baseMsg };
1007
1047
  case "page.html":
@@ -1317,4 +1357,49 @@ function mapToolToMessage(tool, args, tabId) {
1317
1357
  }
1318
1358
  }
1319
1359
 
1320
- module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, readinessExpectations };
1360
+ function applySemanticExpectedIdentity(request, extensionMessage, args) {
1361
+ const fail = (code, message) => {
1362
+ const error = new Error(message);
1363
+ error.code = code;
1364
+ throw error;
1365
+ };
1366
+ const expected = args?.semanticExpectedIdentity;
1367
+ const identityRequired = ["SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE"].includes(extensionMessage?.type);
1368
+ if (expected === undefined) {
1369
+ if (identityRequired) fail("invalid_expected_identity", "semantic expected identity is required for this action");
1370
+ return;
1371
+ }
1372
+ const guardedTypes = [
1373
+ "CLICK_REF", "FORM_FILL", "EXECUTE_NAVIGATE", "EXECUTE_SCROLL", "SCROLL_TO_POSITION",
1374
+ "SEMANTIC_LOCAL_COMPARE", "SEMANTIC_SCROLL_SCOPE",
1375
+ ];
1376
+ if (!extensionMessage || !guardedTypes.includes(extensionMessage.type)) {
1377
+ fail("invalid_expected_identity", "semantic expected identity is not valid for this action");
1378
+ }
1379
+ const domAction = ["CLICK_REF", "FORM_FILL", "SEMANTIC_LOCAL_COMPARE"].includes(extensionMessage.type);
1380
+ const stringFields = ["browserEpoch", "fullUrl", "documentToken", ...(domAction ? ["ref", "role", "name", "type"] : [])];
1381
+ if (!expected || typeof expected !== "object" || stringFields.some((field) => typeof expected[field] !== "string")) {
1382
+ fail("invalid_expected_identity", "invalid semantic expected identity");
1383
+ }
1384
+ if (!Number.isInteger(expected.tabId) || !Number.isInteger(expected.frameId)) {
1385
+ fail("invalid_expected_identity", "invalid semantic expected identity");
1386
+ }
1387
+ const actualFrameId = Number.isInteger(extensionMessage.frameId) ? extensionMessage.frameId : 0;
1388
+ if (
1389
+ request?.browserIdentity?.browserEpoch !== expected.browserEpoch ||
1390
+ request?.target?.tabId !== expected.tabId ||
1391
+ actualFrameId !== expected.frameId ||
1392
+ domAction && extensionMessage.ref && extensionMessage.ref !== expected.ref ||
1393
+ extensionMessage.type === "FORM_FILL" &&
1394
+ (!Array.isArray(extensionMessage.data) || extensionMessage.data.length !== 1 || extensionMessage.data[0]?.ref !== expected.ref)
1395
+ ) {
1396
+ fail("stale_observation", "stale_observation");
1397
+ }
1398
+ extensionMessage.expectedIdentity = {
1399
+ fullUrl: expected.fullUrl,
1400
+ documentToken: expected.documentToken,
1401
+ ...(domAction ? { ref: expected.ref, role: expected.role, name: expected.name, type: expected.type } : {}),
1402
+ };
1403
+ }
1404
+
1405
+ module.exports = { mapToolToMessage, mapComputerAction, formatToolContent, formatToolError, buildProviderUploadMessage, readinessExpectations, applySemanticExpectedIdentity };
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,7 +27,7 @@ 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";
@@ -844,7 +856,7 @@ function currentFrameContext(request) {
844
856
  function applyFrameContextToMessage(request, extensionMessage) {
845
857
  if (!extensionMessage || !FRAME_CONTEXT_MESSAGE_TYPES.has(extensionMessage.type)) return;
846
858
  const context = currentFrameContext(request);
847
- if (context) extensionMessage.frameId = context.frameId;
859
+ if (context && !Number.isInteger(extensionMessage.frameId)) extensionMessage.frameId = context.frameId;
848
860
  }
849
861
 
850
862
  function persistFrameContext(request, frameId, url) {
@@ -1497,6 +1509,7 @@ async function executeMappedHostTool(request, tool, args, tabId) {
1497
1509
  if (!extensionMsg) throw new Error(`Unknown tool: ${tool}`);
1498
1510
  if (request.target?.strict) extensionMsg.strictTarget = true;
1499
1511
  applyFrameContextToMessage(request, extensionMsg);
1512
+ applySemanticExpectedIdentity(request, extensionMsg, args);
1500
1513
  if (extensionMsg.type === "UNSUPPORTED_ACTION") throw new Error(extensionMsg.message);
1501
1514
  if (extensionMsg.type === "LOCAL_WAIT") {
1502
1515
  await abortableDelay(extensionMsg.seconds * 1000, request.signal);
@@ -1720,6 +1733,9 @@ function sendToolResponse(socket, id, result, error) {
1720
1733
  let output = result;
1721
1734
  try {
1722
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
+ }
1723
1739
  } catch (transferFailure) {
1724
1740
  finalError = transferFailure.message;
1725
1741
  }
@@ -1905,6 +1921,12 @@ function handleToolRequest(msg, socket, requestContext = requestStorage.getStore
1905
1921
  }
1906
1922
  if (requestContext.target?.strict) extensionMsg.strictTarget = true;
1907
1923
  applyFrameContextToMessage(requestContext, extensionMsg);
1924
+ try {
1925
+ applySemanticExpectedIdentity(requestContext, extensionMsg, args);
1926
+ } catch (error) {
1927
+ sendToolResponse(socket, originalId, null, error);
1928
+ return;
1929
+ }
1908
1930
 
1909
1931
  if (extensionMsg.type === "UNSUPPORTED_ACTION") {
1910
1932
  sendToolResponse(socket, originalId, null, extensionMsg.message);
@@ -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
  };