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.
- package/README.md +135 -9
- package/agents/gpt-pro.md +2 -2
- package/native/browser-session-store.cjs +30 -2
- package/native/chatgpt-client-selection.cjs +79 -35
- package/native/chatgpt-client-ui.cjs +313 -198
- package/native/chatgpt-client.cjs +7 -2
- package/native/cli.cjs +443 -13
- package/native/doctor.cjs +11 -2
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/file-transfer.cjs +5 -1
- package/native/host-helpers.cjs +40 -3
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +451 -26
- package/native/mcp-server.cjs +26 -1
- package/native/oracle-cli.cjs +2 -2
- package/native/script-options.cjs +33 -0
- package/native/socket-permissions.cjs +114 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +8 -4
- package/native/video-recorder.cjs +444 -0
- package/native/workflow-definition.cjs +5 -0
- package/package.json +6 -6
- package/scripts/install-native-host.cjs +53 -6
- package/skills/surf/SKILL.md +25 -3
|
@@ -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.
|
|
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": "^
|
|
72
|
-
"@vitest/ui": "^
|
|
73
|
-
"pi-subagents": "^0.
|
|
74
|
-
"puppeteer": "25.
|
|
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": "^
|
|
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 = {
|
|
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
|
-
|
|
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(
|
|
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
|
};
|