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.
@@ -0,0 +1,444 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { spawn } = require("child_process");
4
+
5
+ const DEFAULT_VIDEO_FPS = 30;
6
+ const MAX_VIDEO_FPS = 60;
7
+ const MIN_VIDEO_FPS = 1;
8
+ const VIDEO_FRAME_QUEUE_LIMIT = 2;
9
+ const VIDEO_BACKFILL_LIMIT_MS = 5000;
10
+ const DEFAULT_STARTUP_GRACE_MS = 100;
11
+ const DEFAULT_STOP_TIMEOUT_MS = 5000;
12
+
13
+ class VideoRecorderError extends Error {
14
+ constructor(code, message, details = undefined) {
15
+ super(message);
16
+ this.name = "VideoRecorderError";
17
+ this.code = code;
18
+ if (details !== undefined) this.details = details;
19
+ }
20
+ }
21
+
22
+ function parseVideoFps(value, fallback = DEFAULT_VIDEO_FPS) {
23
+ if (value === undefined || value === null || value === "") return fallback;
24
+ if (typeof value === "boolean") {
25
+ throw new VideoRecorderError("video_fps_invalid", "fps must be a number");
26
+ }
27
+ const fps = Number(value);
28
+ if (!Number.isFinite(fps) || fps < MIN_VIDEO_FPS || fps > MAX_VIDEO_FPS) {
29
+ throw new VideoRecorderError("video_fps_invalid", `fps must be between ${MIN_VIDEO_FPS} and ${MAX_VIDEO_FPS}`);
30
+ }
31
+ return fps;
32
+ }
33
+
34
+ function validateVideoOutputPath(output, { createParent = true } = {}) {
35
+ if (typeof output !== "string" || !output.trim()) {
36
+ throw new VideoRecorderError("video_output_invalid", "video output path is required");
37
+ }
38
+ if (output.includes("\0")) {
39
+ throw new VideoRecorderError("video_output_invalid", "video output path contains an invalid null byte");
40
+ }
41
+
42
+ const resolved = path.resolve(output);
43
+ let existing;
44
+ try {
45
+ existing = fs.statSync(resolved);
46
+ } catch (error) {
47
+ if (error?.code !== "ENOENT") {
48
+ throw new VideoRecorderError("video_output_invalid", `Cannot access video output path: ${error.message}`);
49
+ }
50
+ }
51
+ if (existing?.isDirectory()) {
52
+ throw new VideoRecorderError("video_output_invalid", `Video output path is a directory: ${resolved}`);
53
+ }
54
+
55
+ const parent = path.dirname(resolved);
56
+ if (createParent) {
57
+ try {
58
+ fs.mkdirSync(parent, { recursive: true });
59
+ } catch (error) {
60
+ throw new VideoRecorderError("video_output_unwritable", `Cannot create video output directory ${parent}: ${error.message}`);
61
+ }
62
+ }
63
+ try {
64
+ let writableParent = parent;
65
+ while (!fs.existsSync(writableParent)) {
66
+ const next = path.dirname(writableParent);
67
+ if (next === writableParent) break;
68
+ writableParent = next;
69
+ }
70
+ fs.accessSync(writableParent, fs.constants.W_OK);
71
+ if (existing) fs.accessSync(resolved, fs.constants.W_OK);
72
+ } catch (error) {
73
+ throw new VideoRecorderError("video_output_unwritable", `Video output path is not writable: ${resolved}`);
74
+ }
75
+
76
+ return resolved;
77
+ }
78
+
79
+ function buildFfmpegArgs(output, fps) {
80
+ return [
81
+ "-hide_banner",
82
+ "-loglevel",
83
+ "error",
84
+ "-y",
85
+ "-f",
86
+ "image2pipe",
87
+ "-vcodec",
88
+ "mjpeg",
89
+ "-framerate",
90
+ String(fps),
91
+ "-i",
92
+ "pipe:0",
93
+ "-an",
94
+ "-vf",
95
+ "pad=ceil(iw/2)*2:ceil(ih/2)*2",
96
+ "-c:v",
97
+ "libvpx-vp9",
98
+ "-pix_fmt",
99
+ "yuv420p",
100
+ "-r",
101
+ String(fps),
102
+ "-deadline",
103
+ "realtime",
104
+ "-row-mt",
105
+ "1",
106
+ "-f",
107
+ "webm",
108
+ output,
109
+ ];
110
+ }
111
+
112
+ function normalizeChildError(error) {
113
+ if (error instanceof VideoRecorderError) return error;
114
+ const message = error?.message || String(error || "ffmpeg failed");
115
+ if (error?.code === "ENOENT") {
116
+ return new VideoRecorderError("ffmpeg_missing", "ffmpeg was not found on PATH. Install ffmpeg and retry.");
117
+ }
118
+ return new VideoRecorderError("ffmpeg_failed", message);
119
+ }
120
+
121
+ class VideoRecorder {
122
+ constructor({
123
+ output,
124
+ fps = DEFAULT_VIDEO_FPS,
125
+ tabId,
126
+ recorderId,
127
+ spawnImpl = spawn,
128
+ now = () => Date.now(),
129
+ setIntervalImpl = setInterval,
130
+ clearIntervalImpl = clearInterval,
131
+ startupGraceMs = DEFAULT_STARTUP_GRACE_MS,
132
+ stopTimeoutMs = DEFAULT_STOP_TIMEOUT_MS,
133
+ onFailure = () => {},
134
+ } = {}) {
135
+ this.output = validateVideoOutputPath(output, { createParent: false });
136
+ this.fps = parseVideoFps(fps);
137
+ this.tabId = tabId;
138
+ this.recorderId = recorderId;
139
+ this.spawnImpl = spawnImpl;
140
+ this.now = now;
141
+ this.setIntervalImpl = setIntervalImpl;
142
+ this.clearIntervalImpl = clearIntervalImpl;
143
+ this.startupGraceMs = startupGraceMs;
144
+ this.stopTimeoutMs = stopTimeoutMs;
145
+ this.onFailure = onFailure;
146
+
147
+ this.state = "idle";
148
+ this.child = null;
149
+ this.ticker = null;
150
+ this.startedAt = null;
151
+ this.stoppedAt = null;
152
+ this.frames = 0;
153
+ this.capturedFrames = 0;
154
+ this.lastFrame = null;
155
+ this.frameQueue = [];
156
+ this.failure = null;
157
+ this.closeCode = null;
158
+ this.closeSignal = null;
159
+ this.stopPromise = null;
160
+ this._failureNotified = false;
161
+ }
162
+
163
+ get intervalMs() {
164
+ return Math.max(1, Math.round(1000 / this.fps));
165
+ }
166
+
167
+ status() {
168
+ return {
169
+ status: this.state === "active" || this.state === "starting" || this.state === "stopping" ? "active" : this.state,
170
+ recorderId: this.recorderId,
171
+ path: this.output,
172
+ fps: this.fps,
173
+ tabId: this.tabId,
174
+ startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : undefined,
175
+ stoppedAt: this.stoppedAt ? new Date(this.stoppedAt).toISOString() : undefined,
176
+ durationMs: this.startedAt ? Math.max(0, (this.stoppedAt || this.now()) - this.startedAt) : 0,
177
+ frames: this.frames,
178
+ capturedFrames: this.capturedFrames,
179
+ ...(this.failure ? { error: this.failure.message, errorCode: this.failure.code } : {}),
180
+ };
181
+ }
182
+
183
+ async start() {
184
+ if (this.state !== "idle") {
185
+ throw new VideoRecorderError("video_recorder_state", `Cannot start video recorder in state ${this.state}`);
186
+ }
187
+ // Create the parent only after all basic path validation has completed.
188
+ validateVideoOutputPath(this.output, { createParent: true });
189
+
190
+ this.state = "starting";
191
+ this.startedAt = this.now();
192
+ let child;
193
+ try {
194
+ child = this.spawnImpl("ffmpeg", buildFfmpegArgs(this.output, this.fps), {
195
+ stdio: ["pipe", "ignore", "pipe"],
196
+ windowsHide: true,
197
+ });
198
+ } catch (error) {
199
+ this._fail(normalizeChildError(error));
200
+ throw this.failure;
201
+ }
202
+ this.child = child;
203
+ this._attachChildListeners(child);
204
+
205
+ try {
206
+ await this._waitForStartup();
207
+ } catch (error) {
208
+ const normalized = normalizeChildError(error);
209
+ this._fail(normalized);
210
+ await this._killChild();
211
+ throw normalized;
212
+ }
213
+ if (this.state === "failed") throw this.failure;
214
+
215
+ this.state = "active";
216
+ this.ticker = this.setIntervalImpl(() => this._tick(), this.intervalMs);
217
+ // A frame can arrive while the start command is settling; encode it
218
+ // without waiting for the first interval, then keep a wall-clock cadence.
219
+ this._tick();
220
+ return this.status();
221
+ }
222
+
223
+ addFrame(data, receivedAt = this.now()) {
224
+ if (this.state !== "starting" && this.state !== "active") return false;
225
+ if (typeof data !== "string" || !data) return false;
226
+ let frame;
227
+ try {
228
+ frame = Buffer.from(data, "base64");
229
+ } catch {
230
+ return false;
231
+ }
232
+ if (!frame.length) return false;
233
+
234
+ this.capturedFrames += 1;
235
+ this.lastFrame = { data: frame, receivedAt };
236
+ this.frameQueue.push(this.lastFrame);
237
+ while (this.frameQueue.length > VIDEO_FRAME_QUEUE_LIMIT) this.frameQueue.shift();
238
+ // The wall-clock ticker is the sole write path once active, so a busy
239
+ // screencast cannot make ffmpeg run faster than the requested FPS.
240
+ return true;
241
+ }
242
+
243
+ async stop() {
244
+ if (this.stopPromise) return this.stopPromise;
245
+ if (this.state === "idle") {
246
+ throw new VideoRecorderError("video_not_active", "No active video recording");
247
+ }
248
+ if (this.state === "failed") throw this.failure;
249
+
250
+ this.stopPromise = this._stopInternal();
251
+ return this.stopPromise;
252
+ }
253
+
254
+ async dispose() {
255
+ if (this.stopPromise) return this.stopPromise.catch(() => this.status());
256
+ if (this.state === "idle" || this.state === "stopped") return this.status();
257
+ this._clearTicker();
258
+ await this._killChild();
259
+ this.state = "stopped";
260
+ this.stoppedAt = this.now();
261
+ return this.status();
262
+ }
263
+
264
+ _attachChildListeners(child) {
265
+ child.once?.("error", (error) => {
266
+ if (this.state === "stopping" || this.state === "stopped") return;
267
+ this._fail(normalizeChildError(error));
268
+ });
269
+ child.once?.("exit", (code, signal) => {
270
+ this.closeCode = code;
271
+ this.closeSignal = signal;
272
+ if (this.state === "starting") {
273
+ this._fail(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
274
+ } else if (this.state === "active") {
275
+ this._fail(new VideoRecorderError("ffmpeg_exited", `ffmpeg exited while recording${code === null ? "" : ` (code ${code})`}`));
276
+ }
277
+ });
278
+ child.once?.("close", (code, signal) => {
279
+ this.closeCode = code;
280
+ this.closeSignal = signal;
281
+ if (this.state === "starting") {
282
+ this._fail(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
283
+ } else if (this.state === "active") {
284
+ this._fail(new VideoRecorderError("ffmpeg_exited", `ffmpeg exited while recording${code === null ? "" : ` (code ${code})`}`));
285
+ }
286
+ });
287
+ child.stdin?.once?.("error", (error) => {
288
+ if (this.state === "stopping" || this.state === "stopped") return;
289
+ this._fail(normalizeChildError(error));
290
+ });
291
+ child.stderr?.on?.("data", (chunk) => {
292
+ const text = String(chunk || "").trim();
293
+ if (!text) return;
294
+ this.stderr = `${this.stderr || ""}${text}\n`.slice(-8192);
295
+ });
296
+ }
297
+
298
+ _waitForStartup() {
299
+ const child = this.child;
300
+ if (!child) return Promise.reject(new VideoRecorderError("ffmpeg_failed", "ffmpeg process was not created"));
301
+ return new Promise((resolve, reject) => {
302
+ let settled = false;
303
+ let timer;
304
+ const finish = (error) => {
305
+ if (settled) return;
306
+ settled = true;
307
+ if (timer) clearTimeout(timer);
308
+ error ? reject(error) : resolve();
309
+ };
310
+ const onSpawn = () => {
311
+ timer = setTimeout(() => finish(), this.startupGraceMs);
312
+ };
313
+ child.once?.("spawn", onSpawn);
314
+ child.once?.("error", (error) => finish(normalizeChildError(error)));
315
+ child.once?.("exit", (code) => {
316
+ finish(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
317
+ });
318
+ child.once?.("close", (code) => {
319
+ finish(new VideoRecorderError("ffmpeg_exit_early", `ffmpeg exited before recording started${code === null ? "" : ` (code ${code})`}`));
320
+ });
321
+ // Real ChildProcess instances emit spawn, but a small process wrapper may
322
+ // not. Do not leave a recorder hanging forever in that case.
323
+ timer = setTimeout(() => finish(), this.startupGraceMs);
324
+ });
325
+ }
326
+
327
+ _tick() {
328
+ if (this.state !== "active") return;
329
+ const stdin = this.child?.stdin;
330
+ if (!stdin || stdin.destroyed || stdin.writableEnded || stdin.writableNeedDrain) return;
331
+
332
+ const now = this.now();
333
+ let frame = this.frameQueue.shift() || this.lastFrame;
334
+ if (!frame) return;
335
+
336
+ // Never replay a queued frame after an arbitrarily long event-loop stall.
337
+ // The live ticker continues from the latest frame, but stale backfill is
338
+ // intentionally limited to a few seconds.
339
+ if (now - frame.receivedAt > VIDEO_BACKFILL_LIMIT_MS) {
340
+ frame = this.lastFrame;
341
+ }
342
+ try {
343
+ stdin.write(frame.data);
344
+ this.frames += 1;
345
+ } catch (error) {
346
+ this._fail(normalizeChildError(error));
347
+ }
348
+ }
349
+
350
+ async _stopInternal() {
351
+ this._clearTicker();
352
+ // Flush only a pending frame before closing stdin. Replaying the latest
353
+ // already-written frame here would add an unnecessary duplicate at stop.
354
+ if (this.frameQueue.length > 0) this._tick();
355
+ this.state = "stopping";
356
+ this.stoppedAt = this.now();
357
+ const child = this.child;
358
+ if (!child) {
359
+ this.state = "stopped";
360
+ return this.status();
361
+ }
362
+
363
+ const closeResult = await new Promise((resolve) => {
364
+ let settled = false;
365
+ let timer;
366
+ const finish = (code, signal, timedOut = false) => {
367
+ if (settled) return;
368
+ settled = true;
369
+ if (timer) clearTimeout(timer);
370
+ resolve({ code, signal, timedOut });
371
+ };
372
+ child.once?.("close", finish);
373
+ child.once?.("exit", finish);
374
+ try {
375
+ if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) child.stdin.end();
376
+ else if (child.stdin?.destroy) child.stdin.destroy();
377
+ } catch (error) {
378
+ this._fail(normalizeChildError(error));
379
+ finish(null, null);
380
+ }
381
+ timer = setTimeout(() => {
382
+ try { child.kill?.("SIGTERM"); } catch {}
383
+ finish(this.closeCode, this.closeSignal, true);
384
+ }, this.stopTimeoutMs);
385
+ });
386
+ if (closeResult.timedOut) {
387
+ this.failure = new VideoRecorderError("ffmpeg_stop_timeout", `ffmpeg did not exit within ${this.stopTimeoutMs}ms while finalizing the recording`);
388
+ this.state = "failed";
389
+ throw this.failure;
390
+ }
391
+ this.closeCode = closeResult.code;
392
+ this.closeSignal = closeResult.signal;
393
+ if (this.failure) {
394
+ this.state = "failed";
395
+ throw this.failure;
396
+ }
397
+ if (closeResult.code !== null && closeResult.code !== undefined && closeResult.code !== 0) {
398
+ const detail = this.stderr ? `: ${this.stderr.trim()}` : "";
399
+ this.failure = new VideoRecorderError("ffmpeg_failed", `ffmpeg failed while finalizing the recording (code ${closeResult.code})${detail}`);
400
+ this.state = "failed";
401
+ throw this.failure;
402
+ }
403
+ this.state = "stopped";
404
+ return this.status();
405
+ }
406
+
407
+ _clearTicker() {
408
+ if (this.ticker !== null) {
409
+ this.clearIntervalImpl(this.ticker);
410
+ this.ticker = null;
411
+ }
412
+ }
413
+
414
+ _fail(error) {
415
+ if (this.failure) return;
416
+ this.failure = normalizeChildError(error);
417
+ this.state = "failed";
418
+ this._clearTicker();
419
+ if (!this._failureNotified) {
420
+ this._failureNotified = true;
421
+ try { this.onFailure(this.failure, this); } catch {}
422
+ }
423
+ }
424
+
425
+ async _killChild() {
426
+ const child = this.child;
427
+ if (!child || child.killed) return;
428
+ try { child.kill("SIGTERM"); } catch {}
429
+ }
430
+ }
431
+
432
+ module.exports = {
433
+ DEFAULT_STARTUP_GRACE_MS,
434
+ DEFAULT_STOP_TIMEOUT_MS,
435
+ DEFAULT_VIDEO_FPS,
436
+ MAX_VIDEO_FPS,
437
+ MIN_VIDEO_FPS,
438
+ VIDEO_BACKFILL_LIMIT_MS,
439
+ VIDEO_FRAME_QUEUE_LIMIT,
440
+ VideoRecorder,
441
+ VideoRecorderError,
442
+ parseVideoFps,
443
+ validateVideoOutputPath,
444
+ };
@@ -40,6 +40,7 @@ const COMMANDS = {
40
40
  "session.ensure": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
41
41
  "session.list": { effect: "read", recordable: false },
42
42
  "session.info": { primaryArg: "name", effect: "read", recordable: false, argKinds: { name: "name" } },
43
+ "session.cleanup": { effect: "page-write", recordable: false, argKinds: { "idle-after": "duration" } },
43
44
  "session.close": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name" } },
44
45
  "session.rebind": { primaryArg: "name", effect: "page-write", recordable: false, argKinds: { name: "name", tabId: "tab-id" } },
45
46
  "session.reopen": { primaryArg: "name", effect: "navigation", recordable: false, argKinds: { name: "name", url: "url" } },
@@ -64,6 +65,10 @@ const COMMANDS = {
64
65
  "page.text": { effect: "read" },
65
66
  "page.state": { effect: "read" },
66
67
  screenshot: { effect: "read" },
68
+ "video.start": { primaryArg: "output", effect: "read", recordable: false },
69
+ "video.stop": { effect: "read", recordable: false },
70
+ "video.status": { effect: "read", recordable: false },
71
+ "video.restart": { primaryArg: "output", effect: "read", recordable: false },
67
72
  "window.new": { primaryArg: "url", effect: "navigation", argKinds: { url: "url" } },
68
73
  "window.focus": { primaryArg: "id", effect: "navigation", argKinds: { id: "window-id" } },
69
74
  "window.close": { primaryArg: "id", effect: "page-write", argKinds: { id: "window-id" } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -68,14 +68,14 @@
68
68
  "@biomejs/biome": "^2.5.4",
69
69
  "@types/chrome": "^0.2.2",
70
70
  "@types/node": "^26.1.2",
71
- "@vitest/coverage-v8": "^4.1.9",
72
- "@vitest/ui": "^4.1.9",
73
- "pi-subagents": "^0.52.0",
74
- "puppeteer": "25.8.0",
71
+ "@vitest/coverage-v8": "^5.0.0",
72
+ "@vitest/ui": "^5.0.0",
73
+ "pi-subagents": "^0.66.0",
74
+ "puppeteer": "25.10.0",
75
75
  "typebox": "^1.3.11",
76
76
  "typescript": "^7.0.2",
77
77
  "vite": "^8.1.4",
78
- "vitest": "^4.1.9"
78
+ "vitest": "^5.0.0"
79
79
  },
80
80
  "pi": {
81
81
  "extensions": [
@@ -4,6 +4,7 @@ const path = require("path");
4
4
  const os = require("os");
5
5
  const { execFileSync, execSync } = require("child_process");
6
6
  const { parseListenEndpoint } = require("../native/listener.cjs");
7
+ const { normalizeSocketConfig } = require("../native/socket-permissions.cjs");
7
8
  const { getStateDir, loadHostIdentity, loadRegistry } = require("../native/remote-auth.cjs");
8
9
 
9
10
  const HOST_NAME = "surf.browser.host";
@@ -166,7 +167,9 @@ function wslPathToWindowsPath(wslPath) {
166
167
  }
167
168
  }
168
169
 
169
- function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen) {
170
+ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen, socketMode, socketGroup) {
171
+ const socketConfig = normalizeSocketConfig(socketMode, socketGroup);
172
+ assertSocketAccessTargetSupported(socketConfig.mode, socketConfig.group, target);
170
173
  fs.mkdirSync(wrapperDir, { recursive: true });
171
174
 
172
175
  if (target === "wsl-windows") {
@@ -186,9 +189,13 @@ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform
186
189
 
187
190
  const shPath = path.join(wrapperDir, "host-wrapper.sh");
188
191
  const hostDir = path.dirname(hostPath);
192
+ const socketEnvironment = [
193
+ socketConfig.mode === undefined ? "" : `: "\${SURF_SOCKET_MODE:=${socketConfig.mode.toString(8)}}"\nexport SURF_SOCKET_MODE\n`,
194
+ socketConfig.group === undefined ? "" : `: "\${SURF_SOCKET_GROUP:=${socketConfig.group}}"\nexport SURF_SOCKET_GROUP\n`,
195
+ ].join("");
189
196
  const content = `#!/usr/bin/env bash
190
197
  cd "${hostDir}"
191
- ${listen ? `: "\${SURF_LISTEN:=${listen}}"\nexport SURF_LISTEN\n` : ""}exec "${nodePath}" "${hostPath}" "$@"
198
+ ${listen ? `: "\${SURF_LISTEN:=${listen}}"\nexport SURF_LISTEN\n` : ""}${socketEnvironment}exec "${nodePath}" "${hostPath}" "$@"
192
199
  `;
193
200
  fs.writeFileSync(shPath, content);
194
201
  fs.chmodSync(shPath, "755");
@@ -201,6 +208,12 @@ function assertListenTargetSupported(listen, target) {
201
208
  }
202
209
  }
203
210
 
211
+ function assertSocketAccessTargetSupported(socketMode, socketGroup, target) {
212
+ if ((socketMode !== undefined || socketGroup !== undefined) && (target === "win32" || target === "wsl-windows")) {
213
+ throw new Error("--socket-mode and --socket-group are only supported for POSIX native-host wrappers");
214
+ }
215
+ }
216
+
204
217
  function readExistingManifest(manifestPath) {
205
218
  if (!fs.existsSync(manifestPath)) return {};
206
219
  return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -276,7 +289,14 @@ function installWindowsRegistry(browser, extensionId, wrapperPath) {
276
289
 
277
290
  function parseArgs() {
278
291
  const args = process.argv.slice(2);
279
- const result = { extensionId: null, browsers: ["chrome"], target: "auto", listen: undefined };
292
+ const result = {
293
+ extensionId: null,
294
+ browsers: ["chrome"],
295
+ target: "auto",
296
+ listen: undefined,
297
+ socketMode: undefined,
298
+ socketGroup: undefined,
299
+ };
280
300
 
281
301
  for (let i = 0; i < args.length; i++) {
282
302
  const arg = args[i];
@@ -292,6 +312,12 @@ function parseArgs() {
292
312
  } else if (arg === "--listen") {
293
313
  result.listen = args[++i];
294
314
  if (!result.listen || result.listen.startsWith("--")) throw new Error("--listen requires a Tailnet IP and port");
315
+ } else if (arg === "--socket-mode") {
316
+ result.socketMode = args[++i];
317
+ if (!result.socketMode || result.socketMode.startsWith("--")) throw new Error("--socket-mode requires 600 or 660");
318
+ } else if (arg === "--socket-group") {
319
+ result.socketGroup = args[++i];
320
+ if (!result.socketGroup || result.socketGroup.startsWith("--")) throw new Error("--socket-group requires a group name or gid");
295
321
  } else if (arg === "--help" || arg === "-h") {
296
322
  printHelp();
297
323
  process.exit(0);
@@ -322,6 +348,12 @@ Options:
322
348
  Persist an authenticated Tailnet-only listener endpoint.
323
349
  Requires at least one surf remote authorize client first.
324
350
  Supports Tailscale IPv4 or IPv6 addresses; POSIX wrappers only.
351
+ --socket-mode <600|660>
352
+ Persist the local Unix socket mode (default: 600).
353
+ Mode 660 requires --socket-group; POSIX wrappers only.
354
+ --socket-group <group-or-gid>
355
+ Persist the local Unix socket group for mode 660.
356
+ Use a dedicated group; this grants full Surf authority.
325
357
 
326
358
  Examples:
327
359
  node install-native-host.cjs abcdefghijklmnopabcdefghijklmnop
@@ -329,13 +361,14 @@ Examples:
329
361
  node install-native-host.cjs abcdefghijklmnop --browser all
330
362
  node install-native-host.cjs abcdefghijklmnop --target linux
331
363
  node install-native-host.cjs abcdefghijklmnop --listen 100.64.1.2:4321
364
+ node install-native-host.cjs abcdefghijklmnop --socket-mode 660 --socket-group surf
332
365
  `);
333
366
  }
334
367
 
335
368
  function main() {
336
369
  let parsed;
337
370
  try { parsed = parseArgs(); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
338
- const { extensionId, browsers, target, listen } = parsed;
371
+ const { extensionId, browsers, target, listen, socketMode, socketGroup } = parsed;
339
372
 
340
373
  if (!extensionId) {
341
374
  console.error("Error: Extension ID required");
@@ -378,7 +411,12 @@ function main() {
378
411
  }
379
412
 
380
413
  const effectiveTarget = runningInWsl && target !== "linux" ? "wsl-windows" : process.platform;
381
- try { assertListenTargetSupported(listen, effectiveTarget); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
414
+ let socketConfig;
415
+ try {
416
+ socketConfig = normalizeSocketConfig(socketMode, socketGroup);
417
+ assertListenTargetSupported(listen, effectiveTarget);
418
+ assertSocketAccessTargetSupported(socketMode, socketGroup, effectiveTarget);
419
+ } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
382
420
 
383
421
  const nodePath = findNode();
384
422
  if (!nodePath) {
@@ -407,7 +445,15 @@ function main() {
407
445
  console.log(`Wrapper dir: ${wrapperDir}`);
408
446
  console.log("");
409
447
 
410
- const wrapperPath = createWrapper(wrapperDir, nodePath, hostPath, effectiveTarget, listener);
448
+ const wrapperPath = createWrapper(
449
+ wrapperDir,
450
+ nodePath,
451
+ hostPath,
452
+ effectiveTarget,
453
+ listener,
454
+ socketConfig.mode,
455
+ socketConfig.group,
456
+ );
411
457
  console.log(`Created wrapper: ${wrapperPath}`);
412
458
  console.log("");
413
459
 
@@ -450,4 +496,5 @@ module.exports = {
450
496
  createWrapper,
451
497
  writeManifest,
452
498
  assertListenTargetSupported,
499
+ assertSocketAccessTargetSupported,
453
500
  };