surf-cli 2.16.1 → 2.18.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.
@@ -1,16 +1,30 @@
1
1
  const { openClientTransport } = require("./client-transport.cjs");
2
2
  const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
3
3
  const { assembleContext } = require("./oracle-context.cjs");
4
+ const fs = require("fs");
5
+ const path = require("path");
4
6
 
5
7
  const RESULT_TIMEOUT_SECONDS = 20;
6
8
  const POLL_DELAYS_MS = [5000, 10000, 20000, 40000, 60000];
7
9
  const ORACLE_ERROR_CODES = new Set([
8
10
  "auth",
11
+ "attachment_chooser_interception",
12
+ "attachment_file_access",
13
+ "attachment_processing",
14
+ "attachment_selector_drift",
9
15
  "capacity",
16
+ "chat_mode_selection_failed",
17
+ "chat_mode_selector_drift",
18
+ "chat_mode_unavailable",
10
19
  "cloudflare",
11
20
  "context_incomplete",
12
21
  "dispatch_failed",
22
+ "github_tool_disconnected",
23
+ "github_tool_missing",
24
+ "github_tool_selection_failed",
25
+ "github_tool_selector_drift",
13
26
  "harvest_failed",
27
+ "invalid_request",
14
28
  "invalid_transition",
15
29
  "model_verification_failed",
16
30
  "not_found",
@@ -30,8 +44,10 @@ Commands:
30
44
 
31
45
  Ask/follow options:
32
46
  --files <glob> Add context files (repeatable)
33
- --model <model> Select model: instant, thinking, pro, gpt-5.5, gpt-5.6-sol
34
- --effort <effort> Select effort: light, standard, extended, heavy, pro
47
+ --file <path> Attach one local file
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
+ --github Require the ChatGPT Chat tab and GitHub tool
35
51
  --detach Return after dispatch
36
52
  --allow-sensitive Allow deny-listed context files
37
53
 
@@ -56,11 +72,12 @@ function requireOptionValue(argv, index, name) {
56
72
 
57
73
  function parseOptions(argv) {
58
74
  const positional = [];
59
- const options = { files: [] };
60
- const valueOptions = new Set(["files", "model", "effort"]);
75
+ const options = { files: [], file: [] };
76
+ const valueOptions = new Set(["files", "file", "model", "effort"]);
61
77
  const booleanOptions = new Set([
62
78
  "allow-sensitive",
63
79
  "detach",
80
+ "github",
64
81
  "json",
65
82
  "no-lock",
66
83
  "wait",
@@ -75,7 +92,7 @@ function parseOptions(argv) {
75
92
  const name = value.slice(2);
76
93
  if (valueOptions.has(name)) {
77
94
  const optionValue = requireOptionValue(argv, index, name);
78
- if (name === "files") options.files.push(optionValue);
95
+ if (name === "files" || name === "file") options[name].push(optionValue);
79
96
  else options[name] = optionValue;
80
97
  index += 1;
81
98
  } else if (booleanOptions.has(name)) {
@@ -94,14 +111,16 @@ function assertAllowedOptions(command, options) {
94
111
  "allow-sensitive",
95
112
  "detach",
96
113
  "effort",
114
+ "file",
97
115
  "files",
116
+ "github",
98
117
  "model",
99
118
  ]);
100
119
  const allowed = command === "ask" || command === "follow"
101
120
  ? ask
102
121
  : command === "result" ? new Set([...common, "wait"]) : common;
103
122
  for (const [name, value] of Object.entries(options)) {
104
- if (name === "files" && value.length === 0) continue;
123
+ if ((name === "files" || name === "file") && value.length === 0) continue;
105
124
  if (value !== undefined && !allowed.has(name)) {
106
125
  throw codedError("invalid_transition", `--${name} is not supported by oracle ${command}`);
107
126
  }
@@ -131,8 +150,12 @@ function parseOracleCommand(argv) {
131
150
  command,
132
151
  prompt,
133
152
  files: parsed.options.files,
153
+ ...(parsed.options.file.length > 0
154
+ ? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
155
+ : {}),
134
156
  model: parsed.options.model,
135
157
  effort: parsed.options.effort,
158
+ github: parsed.options.github === true,
136
159
  detach: parsed.options.detach === true,
137
160
  allowSensitive: parsed.options["allow-sensitive"] === true,
138
161
  json,
@@ -151,8 +174,12 @@ function parseOracleCommand(argv) {
151
174
  id,
152
175
  prompt,
153
176
  files: parsed.options.files,
177
+ ...(parsed.options.file.length > 0
178
+ ? { file: parsed.options.file.length === 1 ? parsed.options.file[0] : parsed.options.file }
179
+ : {}),
154
180
  model: parsed.options.model,
155
181
  effort: parsed.options.effort,
182
+ github: parsed.options.github === true,
156
183
  detach: parsed.options.detach === true,
157
184
  allowSensitive: parsed.options["allow-sensitive"] === true,
158
185
  json,
@@ -191,12 +218,44 @@ function composeAskRequest(spec, context) {
191
218
  prompt,
192
219
  ...(spec.model ? { model: spec.model } : {}),
193
220
  ...(spec.effort ? { effort: spec.effort } : {}),
221
+ ...(spec.file ? { file: spec.file } : {}),
222
+ ...(spec.github ? { github: true } : {}),
194
223
  ...(context ? { contextManifest: context.manifest } : {}),
195
224
  ...(context?.bundlePath ? { bundlePath: context.bundlePath } : {}),
196
225
  ...(spec.id ? { follow: spec.id } : {}),
197
226
  };
198
227
  }
199
228
 
229
+ async function resolveOracleAttachment(value, cwd = process.cwd()) {
230
+ const values = value === undefined || value === null
231
+ ? []
232
+ : Array.isArray(value) ? value : [value];
233
+ if (values.length > 1) {
234
+ throw codedError(
235
+ "attachment_file_access",
236
+ "Oracle supports one explicit local attachment; provide a single --file path",
237
+ );
238
+ }
239
+ if (values.length === 0) return undefined;
240
+ const requested = values[0];
241
+ if (typeof requested !== "string" || !requested.trim()) {
242
+ throw codedError("attachment_file_access", "Oracle attachment file access failed: --file must be a path");
243
+ }
244
+ const resolved = path.resolve(cwd, requested);
245
+ try {
246
+ const stats = await fs.promises.stat(resolved);
247
+ if (!stats.isFile()) throw new Error("not a regular file");
248
+ await fs.promises.access(resolved, fs.constants.R_OK);
249
+ } catch (error) {
250
+ throw codedError(
251
+ "attachment_file_access",
252
+ `Oracle attachment file access failed for ${resolved}: ${error?.message || error}`,
253
+ { path: resolved },
254
+ );
255
+ }
256
+ return resolved;
257
+ }
258
+
200
259
  function unwrapResponse(response) {
201
260
  if (response?.error) {
202
261
  const message = response.error.message
@@ -389,6 +448,7 @@ async function handleOracleCli(argv, {
389
448
  }
390
449
  }
391
450
 
451
+ const attachment = await resolveOracleAttachment(spec.file, cwd);
392
452
  const context = spec.files.length > 0
393
453
  ? await assembleContext({
394
454
  files: spec.files,
@@ -396,7 +456,7 @@ async function handleOracleCli(argv, {
396
456
  allowSensitive: spec.allowSensitive,
397
457
  })
398
458
  : null;
399
- const request = composeAskRequest(spec, context);
459
+ const request = composeAskRequest({ ...spec, ...(attachment ? { file: attachment } : {}) }, context);
400
460
  const dispatchInterrupt = () => {
401
461
  stderr.write(
402
462
  "Interrupted during dispatch. A job may already have been created. Run surf oracle status or surf oracle list to find it.\n",
@@ -430,5 +490,6 @@ module.exports = {
430
490
  formatOracleOutput,
431
491
  handleOracleCli,
432
492
  parseOracleCommand,
493
+ resolveOracleAttachment,
433
494
  shapeOracleError,
434
495
  };
@@ -1,3 +1,5 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
1
3
  const chatgptClient = require("./chatgpt-client.cjs");
2
4
  const oracleJobs = require("./oracle-jobs.cjs");
3
5
 
@@ -23,6 +25,39 @@ function withJobId(error, jobId, fallbackCode) {
23
25
  return result;
24
26
  }
25
27
 
28
+ async function resolveOracleAttachments(args) {
29
+ const explicit = args?.file === undefined || args?.file === null
30
+ ? []
31
+ : Array.isArray(args.file) ? args.file : [args.file];
32
+ if (explicit.length > 1) {
33
+ throw codedError(
34
+ "attachment_file_access",
35
+ "Oracle supports one explicit local attachment; provide a single --file path",
36
+ );
37
+ }
38
+ const requested = [...explicit, ...(args?.bundlePath ? [args.bundlePath] : [])];
39
+ const resolved = [];
40
+ for (const filePath of requested) {
41
+ if (typeof filePath !== "string" || !filePath.trim()) {
42
+ throw codedError("attachment_file_access", "Oracle attachment file access failed: file path is required");
43
+ }
44
+ const absolutePath = path.resolve(filePath);
45
+ try {
46
+ const stats = await fs.promises.stat(absolutePath);
47
+ if (!stats.isFile()) throw new Error("not a regular file");
48
+ await fs.promises.access(absolutePath, fs.constants.R_OK);
49
+ } catch (error) {
50
+ throw codedError(
51
+ "attachment_file_access",
52
+ `Oracle attachment file access failed for ${absolutePath}: ${error?.message || error}`,
53
+ { path: absolutePath },
54
+ );
55
+ }
56
+ resolved.push(absolutePath);
57
+ }
58
+ return resolved;
59
+ }
60
+
26
61
  function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderUploadMessage, log }) {
27
62
  const closeTab = (request, tabId) => requestCallExtension(
28
63
  request,
@@ -65,6 +100,7 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
65
100
  async function ask(request, args) {
66
101
  assertLocalOracleRequest(request);
67
102
  const model = args.model ? chatgptClient.normalizeChatGPTModelChoice(args.model) : null;
103
+ const explicitAttachmentPaths = await resolveOracleAttachments({ ...args, bundlePath: undefined });
68
104
  const created = oracleJobs.createJob({
69
105
  prompt: args.prompt,
70
106
  contextManifest: args.contextManifest,
@@ -72,11 +108,16 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
72
108
  effortRequested: args.effort ?? null,
73
109
  follow: args.follow ?? null,
74
110
  requestId: args.requestId ?? null,
111
+ attachmentPaths: explicitAttachmentPaths,
112
+ github: args.github === true,
75
113
  });
76
114
  if (created.requestDeduped) return oracleJobs.getJob(created.id);
77
115
  let createdTabId = null;
78
116
 
79
117
  try {
118
+ const attachmentPaths = args?.bundlePath
119
+ ? [...explicitAttachmentPaths, ...(await resolveOracleAttachments({ bundlePath: args.bundlePath }))]
120
+ : explicitAttachmentPaths;
80
121
  let parent = null;
81
122
  if (args.follow) {
82
123
  parent = oracleJobs.getJob(args.follow);
@@ -93,7 +134,10 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
93
134
  prompt: args.prompt,
94
135
  model,
95
136
  effort: args.effort,
96
- file: args.bundlePath,
137
+ file: attachmentPaths.length > 0
138
+ ? attachmentPaths.length === 1 ? attachmentPaths[0] : attachmentPaths
139
+ : undefined,
140
+ ...(args.github === true ? { github: true } : {}),
97
141
  startUrl: parent?.conversationUrl,
98
142
  createTab: async () => {
99
143
  const tabInfo = await browserOptions(request).createTab();
@@ -304,4 +348,4 @@ function createOracleHost({ queueAiRequest, requestCallExtension, buildProviderU
304
348
  };
305
349
  }
306
350
 
307
- module.exports = { assertLocalOracleRequest, createOracleHost };
351
+ module.exports = { assertLocalOracleRequest, createOracleHost, resolveOracleAttachments };
@@ -47,13 +47,15 @@ function stableJson(value) {
47
47
  return JSON.stringify(value) ?? "null";
48
48
  }
49
49
 
50
- function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow }) {
50
+ function requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github }) {
51
51
  return promptDigest(stableJson({
52
52
  promptDigest: promptDigest(prompt),
53
53
  contextManifest: contextManifest ?? {},
54
54
  model: model ?? null,
55
55
  effortRequested: effortRequested ?? null,
56
56
  follow: follow ?? null,
57
+ attachmentPaths: attachmentPaths ?? [],
58
+ github: github === true,
57
59
  }));
58
60
  }
59
61
 
@@ -92,13 +94,13 @@ function readJobs(root = getPrivateStateRoot()) {
92
94
  .map((job) => hydrateJobMetadata(job, root));
93
95
  }
94
96
 
95
- function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null }) {
97
+ function createJob({ prompt, contextManifest = {}, model = null, effortRequested = null, follow = null, requestId = null, attachmentPaths = [], github = false }) {
96
98
  const root = getPrivateStateRoot();
97
99
  const base = oracleRoot(root);
98
100
  ensurePrivateDir(base, root);
99
101
  const safeRequestId = normalizedRequestId(requestId);
100
102
  const fingerprint = safeRequestId
101
- ? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow })
103
+ ? requestFingerprint({ prompt, contextManifest, model, effortRequested, follow, attachmentPaths, github })
102
104
  : null;
103
105
  if (safeRequestId) {
104
106
  const existing = readJobs(root).find((job) => job.requestId === safeRequestId);
@@ -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
+ };
@@ -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",
@@ -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 };