surf-cli 2.17.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.
@@ -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: {
@@ -269,7 +294,7 @@ const TOOL_SCHEMAS = {
269
294
  desc: "Ask ChatGPT through the browser session",
270
295
  schema: {
271
296
  query: z.string().describe("Question or prompt"),
272
- model: z.string().optional().describe("ChatGPT model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol, or a visible model label"),
297
+ model: z.string().optional().describe("ChatGPT model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5"),
273
298
  "with-page": z.boolean().optional().describe("Include current page context"),
274
299
  file: z.string().optional().describe("One attachment path"),
275
300
  timeout: z.number().optional().describe("Timeout in seconds")
@@ -45,8 +45,8 @@ Commands:
45
45
  Ask/follow options:
46
46
  --files <glob> Add context files (repeatable)
47
47
  --file <path> Attach one local file
48
- --model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
49
- --effort <effort> Select effort: light, standard, extended, heavy, pro
48
+ --model <model> Select model: gpt-6-astra, latest, gpt-5.6-sol, gpt-5.5
49
+ --effort <effort> Select effort: instant, medium, high, xhigh, pro
50
50
  --github Require the ChatGPT Chat tab and GitHub tool
51
51
  --detach Return after dispatch
52
52
  --allow-sensitive Allow deny-listed context files
@@ -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,114 @@
1
+ const fs = require("fs");
2
+ const { execFileSync } = require("child_process");
3
+
4
+ const DEFAULT_SOCKET_MODE = 0o600;
5
+ const ALLOWED_SOCKET_MODES = new Set([0o600, 0o660]);
6
+ const MAX_GID = 0xfffffffe;
7
+ const SOCKET_GROUP_PATTERN = /^(?:\d+|[A-Za-z_][A-Za-z0-9_.-]*)$/;
8
+
9
+ function parseSocketMode(value) {
10
+ if (value === undefined) return DEFAULT_SOCKET_MODE;
11
+ if (typeof value === "number" && ALLOWED_SOCKET_MODES.has(value)) return value;
12
+ if (value === 600) return DEFAULT_SOCKET_MODE;
13
+ if (value === 660) return 0o660;
14
+ const text = String(value).trim();
15
+ if (!/^0?(?:600|660)$/.test(text)) {
16
+ throw new Error("SURF_SOCKET_MODE must be 600 or 660");
17
+ }
18
+ return Number.parseInt(text, 8);
19
+ }
20
+
21
+ function validateSocketGroup(value) {
22
+ if (value === undefined) return undefined;
23
+ if (value === null || typeof value === "boolean") {
24
+ throw new Error("SURF_SOCKET_GROUP must be a numeric gid or a simple group name");
25
+ }
26
+ const group = String(value).trim();
27
+ if (!SOCKET_GROUP_PATTERN.test(group)) {
28
+ throw new Error("SURF_SOCKET_GROUP must be a numeric gid or a simple group name");
29
+ }
30
+ if (/^\d+$/.test(group)) {
31
+ const gid = Number(group);
32
+ if (!Number.isSafeInteger(gid) || gid < 0 || gid > MAX_GID) {
33
+ throw new Error("SURF_SOCKET_GROUP gid is out of range");
34
+ }
35
+ }
36
+ return group;
37
+ }
38
+
39
+ function normalizeSocketConfig(socketMode, socketGroup) {
40
+ const mode = socketMode === undefined ? undefined : parseSocketMode(socketMode);
41
+ const group = socketGroup === undefined ? undefined : validateSocketGroup(socketGroup);
42
+ if (mode === 0o660 && !group) {
43
+ throw new Error("SURF_SOCKET_MODE=660 requires SURF_SOCKET_GROUP");
44
+ }
45
+ return { mode, group };
46
+ }
47
+
48
+ function resolveSocketGroup(value) {
49
+ const group = validateSocketGroup(value);
50
+ if (group === undefined) return undefined;
51
+ if (/^\d+$/.test(group)) return Number(group);
52
+
53
+ const command = process.platform === "darwin" ? "dscl" : "getent";
54
+ const args = process.platform === "darwin"
55
+ ? [".", "-read", `/Groups/${group}`, "PrimaryGroupID"]
56
+ : ["group", group];
57
+ let output;
58
+ try {
59
+ output = execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
60
+ } catch (error) {
61
+ throw new Error(`could not resolve socket group ${group}: ${error.message}`);
62
+ }
63
+ const match = process.platform === "darwin"
64
+ ? output.match(/\bPrimaryGroupID:\s*(\d+)\b/)
65
+ : output.trim().split(/\r?\n/, 1)[0]?.split(":")[2]?.match(/^\d+$/);
66
+ const gid = Number(match?.[1] || match?.[0]);
67
+ if (!Number.isSafeInteger(gid) || gid < 0 || gid > MAX_GID) {
68
+ throw new Error(`could not resolve socket group ${group}`);
69
+ }
70
+ return gid;
71
+ }
72
+
73
+ function resolveSocketPermissions(socketMode, socketGroup) {
74
+ const config = normalizeSocketConfig(socketMode, socketGroup);
75
+ const mode = config.mode ?? DEFAULT_SOCKET_MODE;
76
+ const gid = resolveSocketGroup(config.group);
77
+ return { mode, group: config.group, gid };
78
+ }
79
+
80
+ function assertSocketPath(socketPath) {
81
+ let stat;
82
+ try {
83
+ stat = fs.lstatSync(socketPath);
84
+ } catch (error) {
85
+ throw new Error(`could not inspect local socket: ${error.message}`);
86
+ }
87
+ if (stat.isSymbolicLink()) throw new Error("refusing symbolic-link local socket");
88
+ if (!stat.isSocket()) throw new Error("local socket path is not a Unix socket");
89
+ return stat;
90
+ }
91
+
92
+ function applySocketPermissions(socketPath, permissions) {
93
+ const before = assertSocketPath(socketPath);
94
+ if (permissions.gid !== undefined) {
95
+ fs.chownSync(socketPath, before.uid, permissions.gid);
96
+ }
97
+ fs.chmodSync(socketPath, permissions.mode);
98
+ const after = assertSocketPath(socketPath);
99
+ if ((after.mode & 0o7777) !== permissions.mode) {
100
+ throw new Error(`local socket mode is not ${permissions.mode.toString(8)}`);
101
+ }
102
+ if (permissions.gid !== undefined && after.gid !== permissions.gid) {
103
+ throw new Error(`local socket group is not ${permissions.group}`);
104
+ }
105
+ return after;
106
+ }
107
+
108
+ module.exports = {
109
+ applySocketPermissions,
110
+ normalizeSocketConfig,
111
+ parseSocketMode,
112
+ resolveSocketPermissions,
113
+ validateSocketGroup,
114
+ };
@@ -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 };
@@ -21,7 +21,7 @@ const BROWSER_READ_TOOLS = new Set([
21
21
  ]);
22
22
 
23
23
  const BROWSER_WRITE_TOOLS = new Set([
24
- "session.new", "session.ensure", "session.close", "session.rebind", "session.reopen",
24
+ "session.new", "session.ensure", "session.cleanup", "session.close", "session.rebind", "session.reopen",
25
25
  "tab.new", "new_tab", "tabs_create",
26
26
  "tab.move", "tab.switch", "switch_tab",
27
27
  "tab.group", "tab.ungroup",
@@ -36,7 +36,7 @@ const BROWSER_WRITE_TARGETED_TOOLS = new Set([
36
36
  ]);
37
37
 
38
38
  const TAB_TOOLS = new Set([
39
- "ai", "computer", "batch", "record", "animate-audit", "perf-audit",
39
+ "ai", "computer", "batch", "record", "video.start", "animate-audit", "perf-audit",
40
40
  "navigate", "go", "back", "forward", "reload", "tab.reload",
41
41
  "screenshot", "snap", "resize",
42
42
  "page.read", "read_page", "page.text", "get_page_text", "page.html", "page.save", "page.state",
@@ -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",
@@ -87,10 +87,14 @@ function classifyTool(tool, args = {}) {
87
87
  return { scope: "browser-write", targetUse: "default-tab" };
88
88
  }
89
89
  if (BROWSER_WRITE_TOOLS.has(tool)) return { scope: "browser-write", targetUse: "browser" };
90
+ if (tool === "video.stop" || tool === "video.status" || tool === "video.restart") {
91
+ return { scope: "host", targetUse: "host", resourceKeys: ["video-recorder"] };
92
+ }
90
93
  if (TAB_TOOLS.has(tool)) {
91
94
  const resourceKeys = [];
92
95
  if (tool === "network.export" && typeof args.output === "string") resourceKeys.push(`file:${path.resolve(args.output)}`);
93
96
  if (tool.startsWith("playbook.record.")) resourceKeys.push("playbook-recorder");
97
+ if (tool === "video.start") resourceKeys.push("video-recorder");
94
98
  return { scope: "tab", targetUse: "default-tab", resourceKeys };
95
99
  }
96
100
  return { scope: "browser-write", targetUse: "browser", conservative: true };