surf-cli 2.7.2 → 2.9.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.
Files changed (45) hide show
  1. package/README.md +208 -13
  2. package/dist/content/index.js +116 -0
  3. package/dist/content/index.js.map +1 -0
  4. package/dist/manifest.json +2 -11
  5. package/dist/options/options.js +3 -3
  6. package/dist/options/options.js.map +1 -1
  7. package/dist/service-worker/index.js +261 -61
  8. package/dist/service-worker/index.js.map +1 -1
  9. package/native/abort.cjs +65 -0
  10. package/native/ai-queue.cjs +64 -0
  11. package/native/aistudio-build.cjs +21 -13
  12. package/native/aistudio-client.cjs +40 -20
  13. package/native/browser-lock.cjs +169 -0
  14. package/native/chatgpt-client.cjs +63 -30
  15. package/native/cli.cjs +947 -460
  16. package/native/client-transport.cjs +168 -0
  17. package/native/config.cjs +2 -2
  18. package/native/do-executor.cjs +25 -51
  19. package/native/do-parser.cjs +12 -0
  20. package/native/doctor.cjs +633 -0
  21. package/native/endpoint.cjs +174 -0
  22. package/native/file-transfer.cjs +734 -0
  23. package/native/gemini-client.cjs +244 -88
  24. package/native/grok-client.cjs +321 -212
  25. package/native/host-helpers.cjs +88 -16
  26. package/native/host-sessions.cjs +283 -0
  27. package/native/host.cjs +811 -616
  28. package/native/listener.cjs +20 -0
  29. package/native/mcp-server.cjs +60 -62
  30. package/native/network-export.cjs +113 -0
  31. package/native/perplexity-client.cjs +46 -17
  32. package/native/remote-auth.cjs +279 -0
  33. package/native/remote-transport.cjs +337 -0
  34. package/native/request-pending.cjs +148 -0
  35. package/native/socket-path.cjs +46 -0
  36. package/package.json +11 -9
  37. package/scripts/install-native-host.cjs +184 -51
  38. package/scripts/uninstall-native-host.cjs +93 -15
  39. package/skills/README.md +11 -5
  40. package/skills/deep-x-research/SKILL.md +106 -0
  41. package/skills/surf/SKILL.md +77 -22
  42. package/dist/content/accessibility-tree.js +0 -11
  43. package/dist/content/accessibility-tree.js.map +0 -1
  44. package/dist/content/visual-indicator.js +0 -111
  45. package/dist/content/visual-indicator.js.map +0 -1
@@ -0,0 +1,734 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const fsp = fs.promises;
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const TRANSFER_VERSION = 1;
8
+ const DEFAULT_LIMITS = Object.freeze({
9
+ maxChunkBytes: 256 * 1024,
10
+ maxFileBytes: 256 * 1024 * 1024,
11
+ maxSessionBytes: 512 * 1024 * 1024,
12
+ maxFiles: 32,
13
+ });
14
+ const TRANSFER_TYPES = new Set([
15
+ "transfer_begin", "transfer_ready", "transfer_chunk", "transfer_end",
16
+ "transfer_complete", "transfer_error",
17
+ ]);
18
+
19
+ function transferError(message, code = "SURF_TRANSFER_ERROR") {
20
+ const error = new Error(message);
21
+ error.code = code;
22
+ return error;
23
+ }
24
+
25
+ function isSha256(value) {
26
+ return typeof value === "string" && /^[a-f0-9]{64}$/i.test(value);
27
+ }
28
+
29
+ function decodeBase64(value, maxBytes) {
30
+ if (typeof value !== "string" || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
31
+ throw transferError("transfer chunk is not strict base64", "SURF_TRANSFER_BASE64");
32
+ }
33
+ const decoded = Buffer.from(value, "base64");
34
+ if (decoded.toString("base64") !== value) throw transferError("transfer chunk is not canonical base64", "SURF_TRANSFER_BASE64");
35
+ if (decoded.length === 0) throw transferError("transfer chunk must not be empty", "SURF_TRANSFER_CHUNK");
36
+ if (decoded.length > maxBytes) throw transferError("transfer chunk exceeds limit", "SURF_TRANSFER_CHUNK_LIMIT");
37
+ return decoded;
38
+ }
39
+
40
+ function parsePathDescriptor(value, { mode = "remote", field = "path" } = {}) {
41
+ if (typeof value !== "string" || value.length === 0) throw transferError(`${field} must be a path string`, "SURF_PATH_INVALID");
42
+ const original = value;
43
+ if (value.startsWith("remote:")) {
44
+ if (mode === "local") throw transferError(`${field} remote: paths are not allowed in local mode`, "SURF_PATH_REMOTE_LOCAL");
45
+ const resolved = value.slice("remote:".length);
46
+ if (!path.isAbsolute(resolved)) throw transferError(`${field} remote: path must be absolute`, "SURF_PATH_REMOTE_RELATIVE");
47
+ return { kind: "remote", path: resolved, original };
48
+ }
49
+ if (value.startsWith("local:")) {
50
+ const resolved = value.slice("local:".length);
51
+ if (!resolved) throw transferError(`${field} local: path is empty`, "SURF_PATH_INVALID");
52
+ return { kind: "local", path: path.resolve(resolved), original };
53
+ }
54
+ return { kind: mode === "local" ? "local" : "local", path: path.resolve(value), original };
55
+ }
56
+
57
+ function rewritePath(value, descriptor) {
58
+ if (!descriptor) return value;
59
+ return descriptor.original;
60
+ }
61
+
62
+ function randomId(prefix = "transfer") {
63
+ return `${prefix}-${crypto.randomBytes(16).toString("hex")}`;
64
+ }
65
+
66
+ async function createStagingDirectory(root, connectionId = randomId("connection")) {
67
+ const dir = await fsp.mkdtemp(path.join(root || os.tmpdir(), `surf-transfer-${connectionId}-`));
68
+ try {
69
+ await fsp.chmod(dir, 0o700);
70
+ return dir;
71
+ } catch (error) {
72
+ await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ function randomStagingPath(directory, extension = "") {
78
+ const suffix = extension && /^\.[A-Za-z0-9]{1,16}$/.test(extension) ? extension : "";
79
+ return path.join(directory, `${crypto.randomBytes(20).toString("hex")}${suffix}`);
80
+ }
81
+
82
+ async function assertRegularFile(filePath, field = "file") {
83
+ const stats = await fsp.stat(filePath);
84
+ if (!stats.isFile()) throw transferError(`${field} must be a regular file`, "SURF_PATH_NOT_REGULAR");
85
+ return stats;
86
+ }
87
+
88
+ function createTransferState({ directory, writer, limits = {}, completedTtlMs = 60000, onActivity = () => {}, onCleanup = () => {} } = {}) {
89
+ const caps = { ...DEFAULT_LIMITS, ...limits };
90
+ const transfers = new Map();
91
+ const completed = new Map();
92
+ const outboundWaiters = new Map();
93
+ const seenIds = new Set();
94
+ let usedBytes = 0;
95
+ let fileCount = 0;
96
+ let closed = false;
97
+
98
+ const remove = async (state) => {
99
+ if (!state || state.removed) return;
100
+ state.removed = true;
101
+ transfers.delete(state.id);
102
+ completed.delete(state.id);
103
+ if (state.completedTimer) clearTimeout(state.completedTimer);
104
+ try { await state.file?.close(); } catch {}
105
+ try { await fsp.rm(state.filePath, { force: true }); } catch {}
106
+ try { await onCleanup(state); } catch {}
107
+ };
108
+
109
+ const fail = async (state, error) => {
110
+ await remove(state);
111
+ throw error;
112
+ };
113
+
114
+ const begin = async (frame) => {
115
+ if (closed) throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
116
+ if (frame.version !== TRANSFER_VERSION || !TRANSFER_TYPES.has(frame.type) || frame.type !== "transfer_begin") throw transferError("invalid transfer begin frame", "SURF_TRANSFER_PROTOCOL");
117
+ if (typeof frame.transferId !== "string" || seenIds.has(frame.transferId) || transfers.has(frame.transferId) || completed.has(frame.transferId)) throw transferError("duplicate or invalid transfer ID", "SURF_TRANSFER_DUPLICATE");
118
+ if (frame.direction !== "upload" && frame.direction !== "download") throw transferError("invalid transfer direction", "SURF_TRANSFER_PROTOCOL");
119
+ if (!Number.isSafeInteger(frame.size) || frame.size < 0 || frame.size > caps.maxFileBytes) throw transferError("declared transfer size exceeds limit", "SURF_TRANSFER_FILE_LIMIT");
120
+ if (!isSha256(frame.sha256)) throw transferError("declared transfer hash is invalid", "SURF_TRANSFER_HASH");
121
+ if (fileCount >= caps.maxFiles) throw transferError("transfer file count limit exceeded", "SURF_TRANSFER_COUNT_LIMIT");
122
+ if (usedBytes + frame.size > caps.maxSessionBytes) throw transferError("transfer session byte limit exceeded", "SURF_TRANSFER_SESSION_LIMIT");
123
+ if (frame.direction === "download") throw transferError("host transfer state accepts uploads only", "SURF_TRANSFER_DIRECTION");
124
+ seenIds.add(frame.transferId);
125
+ const state = {
126
+ id: frame.transferId,
127
+ direction: frame.direction,
128
+ size: frame.size,
129
+ sha256: frame.sha256.toLowerCase(),
130
+ sequence: 0,
131
+ received: 0,
132
+ hash: crypto.createHash("sha256"),
133
+ filePath: randomStagingPath(directory),
134
+ file: null,
135
+ removed: false,
136
+ };
137
+ state.file = await fsp.open(state.filePath, "wx", 0o600);
138
+ await fsp.chmod(state.filePath, 0o600);
139
+ transfers.set(state.id, state);
140
+ usedBytes += state.size;
141
+ fileCount += 1;
142
+ onActivity();
143
+ await writer.send({ type: "transfer_ready", version: TRANSFER_VERSION, transferId: state.id });
144
+ return state;
145
+ };
146
+
147
+ const chunk = async (frame) => {
148
+ const state = transfers.get(frame.transferId);
149
+ if (!state || state.direction !== "upload") throw transferError("unknown transfer ID or direction", "SURF_TRANSFER_UNKNOWN");
150
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence !== state.sequence) return fail(state, transferError("transfer chunks are out of order", "SURF_TRANSFER_SEQUENCE"));
151
+ let data;
152
+ try { data = decodeBase64(frame.data, caps.maxChunkBytes); } catch (error) { return fail(state, error); }
153
+ if (state.received + data.length > state.size) return fail(state, transferError("transfer exceeds declared size", "SURF_TRANSFER_SIZE"));
154
+ await state.file.write(data);
155
+ state.hash.update(data);
156
+ state.received += data.length;
157
+ state.sequence += 1;
158
+ onActivity();
159
+ };
160
+
161
+ const end = async (frame) => {
162
+ const state = transfers.get(frame.transferId);
163
+ if (!state || state.direction !== "upload") throw transferError("unknown transfer ID or direction", "SURF_TRANSFER_UNKNOWN");
164
+ if (state.received !== state.size || state.hash.digest("hex") !== state.sha256) return fail(state, transferError("transfer size or SHA-256 mismatch", "SURF_TRANSFER_INTEGRITY"));
165
+ await state.file.close();
166
+ transfers.delete(state.id);
167
+ completed.set(state.id, state);
168
+ state.completedTimer = setTimeout(() => { discardCompleted(state.id).catch(() => {}); }, completedTtlMs);
169
+ state.complete = true;
170
+ await writer.send({ type: "transfer_complete", version: TRANSFER_VERSION, transferId: state.id, size: state.size, sha256: state.sha256 });
171
+ onActivity();
172
+ return state;
173
+ };
174
+
175
+ const handle = async (frame) => {
176
+ if (!frame || !TRANSFER_TYPES.has(frame.type)) return false;
177
+ if (frame.version !== TRANSFER_VERSION) throw transferError("unsupported transfer protocol version", "SURF_TRANSFER_PROTOCOL");
178
+ if (frame.type === "transfer_complete" && outboundWaiters.has(frame.transferId)) {
179
+ outboundWaiters.get(frame.transferId).resolve(frame);
180
+ outboundWaiters.delete(frame.transferId);
181
+ return true;
182
+ }
183
+ if (frame.type === "transfer_begin") await begin(frame);
184
+ else if (frame.type === "transfer_chunk") await chunk(frame);
185
+ else if (frame.type === "transfer_end") await end(frame);
186
+ else throw transferError("unexpected transfer control frame", "SURF_TRANSFER_PROTOCOL");
187
+ return true;
188
+ };
189
+
190
+ const reserveOutbound = (size, id) => {
191
+ if (id && seenIds.has(id)) throw transferError("duplicate transfer ID", "SURF_TRANSFER_DUPLICATE");
192
+ if (id) seenIds.add(id);
193
+ if (size > caps.maxFileBytes) throw transferError("transfer file size limit exceeded", "SURF_TRANSFER_FILE_LIMIT");
194
+ if (fileCount >= caps.maxFiles) throw transferError("transfer file count limit exceeded", "SURF_TRANSFER_COUNT_LIMIT");
195
+ if (usedBytes + size > caps.maxSessionBytes) throw transferError("transfer session byte limit exceeded", "SURF_TRANSFER_SESSION_LIMIT");
196
+ usedBytes += size; fileCount += 1;
197
+ };
198
+ const releaseOutbound = (size) => {
199
+ usedBytes = Math.max(0, usedBytes - size);
200
+ fileCount = Math.max(0, fileCount - 1);
201
+ };
202
+ const waitOutbound = (id) => new Promise((resolve, reject) => outboundWaiters.set(id, { resolve, reject }));
203
+ const discardCompleted = async (id) => {
204
+ const state = completed.get(id);
205
+ if (!state) return false;
206
+ completed.delete(id);
207
+ if (state.completedTimer) clearTimeout(state.completedTimer);
208
+ try { await fsp.rm(state.filePath, { force: true }); } catch {}
209
+ try { await onCleanup(state); } catch {}
210
+ return true;
211
+ };
212
+ const takeCompleted = (id) => {
213
+ const state = completed.get(id);
214
+ if (state) {
215
+ completed.delete(id);
216
+ if (state.completedTimer) clearTimeout(state.completedTimer);
217
+ }
218
+ return state;
219
+ };
220
+ const cleanup = async () => {
221
+ closed = true;
222
+ await Promise.all([...transfers.values()].map(remove));
223
+ await Promise.all([...completed.keys()].map(discardCompleted));
224
+ transfers.clear();
225
+ for (const waiter of outboundWaiters.values()) waiter.reject(transferError("transfer connection closed", "SURF_TRANSFER_CLOSED"));
226
+ outboundWaiters.clear();
227
+ try { await fsp.rm(directory, { recursive: true, force: true }); } catch {}
228
+ };
229
+ return { handle, cleanup, reserveOutbound, releaseOutbound, waitOutbound, takeCompleted, discardCompleted, get directory() { return directory; }, get transfers() { return transfers; }, get completed() { return completed; }, get usedBytes() { return usedBytes; }, get fileCount() { return fileCount; } };
230
+ }
231
+
232
+ async function cleanupFilePaths(paths = []) {
233
+ const owned = paths.splice(0, paths.length);
234
+ await Promise.all(owned.map((filePath) => fsp.rm(filePath, { force: true }).catch(() => {})));
235
+ }
236
+
237
+ async function hashFile(filePath) {
238
+ const hash = crypto.createHash("sha256");
239
+ let size = 0;
240
+ for await (const chunk of fs.createReadStream(filePath)) { hash.update(chunk); size += chunk.length; }
241
+ return { size, sha256: hash.digest("hex") };
242
+ }
243
+
244
+ async function writeAtomicDownload(destination, chunks, expected) {
245
+ const directory = path.dirname(destination);
246
+ await fsp.mkdir(directory, { recursive: true });
247
+ const temp = path.join(directory, `.${path.basename(destination)}.surf-${crypto.randomBytes(12).toString("hex")}.tmp`);
248
+ const handle = await fsp.open(temp, "wx", 0o600);
249
+ const hash = crypto.createHash("sha256");
250
+ let size = 0;
251
+ try {
252
+ for await (const chunk of chunks) { size += chunk.length; if (size > expected.size) throw transferError("download exceeds declared size", "SURF_TRANSFER_SIZE"); hash.update(chunk); await handle.write(chunk); }
253
+ await handle.close();
254
+ if (size !== expected.size || hash.digest("hex") !== expected.sha256.toLowerCase()) throw transferError("download size or SHA-256 mismatch", "SURF_TRANSFER_INTEGRITY");
255
+ await fsp.chmod(temp, 0o600);
256
+ await fsp.rename(temp, destination);
257
+ } catch (error) {
258
+ await handle.close().catch(() => {});
259
+ await fsp.rm(temp, { force: true }).catch(() => {});
260
+ throw error;
261
+ }
262
+ return destination;
263
+ }
264
+
265
+ function validateLocalToolPaths(tool, args = {}) {
266
+ const normalized = { ...args };
267
+ const normalize = (field, value) => parsePathDescriptor(value, { mode: "local", field }).path;
268
+ if (tool === "upload" && args.files !== undefined) {
269
+ normalized.files = Array.isArray(args.files)
270
+ ? args.files.map((value) => normalize("files", value))
271
+ : String(args.files).split(",").map((value) => normalize("files", value.trim())).join(",");
272
+ }
273
+ if (tool === "screenshot") {
274
+ if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
275
+ for (const field of ["savePath", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
276
+ }
277
+ if (tool === "network.export") {
278
+ if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
279
+ if (args.jsonl !== undefined && typeof args.jsonl !== "boolean") throw transferError("network.export jsonl must be boolean", "SURF_PATH_FIELD");
280
+ if (args.har === true && args.jsonl === true) throw transferError("network.export cannot combine HAR and JSONL", "SURF_PATH_FIELD");
281
+ normalized.output = args.output === undefined
282
+ ? generatedClientPath("network-export", args.har === true ? ".har" : args.jsonl === true ? ".jsonl" : ".json")
283
+ : normalize("output", args.output);
284
+ }
285
+ if (tool === "gemini") for (const field of ["file", "edit-image", "generate-image", "output"]) if (args[field] !== undefined) normalized[field] = normalize(field, args[field]);
286
+ if (tool === "chatgpt" && args.file !== undefined) normalized.file = Array.isArray(args.file) ? args.file.map((value) => normalize("file", value)) : normalize("file", args.file);
287
+ return normalized;
288
+ }
289
+
290
+ function isPlainObject(value) {
291
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
292
+ const prototype = Object.getPrototypeOf(value);
293
+ return prototype === Object.prototype || prototype === null;
294
+ }
295
+
296
+ function rewriteTransferPaths(value, rewrites, depth = 0, seen = new Set(), budget = { remaining: 1000 }) {
297
+ if (budget.remaining-- <= 0 || depth > 8) throw transferError("response exceeds path rewrite limits", "SURF_TRANSFER_RESPONSE_LIMIT");
298
+ if (value === null || value === undefined) return value;
299
+ if (typeof value === "string") {
300
+ return rewrites.reduce((result, rewrite) => result.replaceAll(rewrite.path, rewrite.original), value);
301
+ }
302
+ if (Array.isArray(value)) return value.map((entry) => rewriteTransferPaths(entry, rewrites, depth + 1, seen, budget));
303
+ if (!isPlainObject(value)) return value;
304
+ if (seen.has(value)) throw transferError("response contains a cyclic value", "SURF_TRANSFER_RESPONSE_LIMIT");
305
+ seen.add(value);
306
+ const result = Object.create(Object.getPrototypeOf(value));
307
+ for (const [key, entry] of Object.entries(value)) {
308
+ Object.defineProperty(result, key, {
309
+ value: rewriteTransferPaths(entry, rewrites, depth + 1, seen, budget),
310
+ enumerable: true,
311
+ configurable: true,
312
+ writable: true,
313
+ });
314
+ }
315
+ return result;
316
+ }
317
+
318
+ const AUTO_SCREENSHOT_TOOLS = Object.freeze(["click", "type", "key", "smart_type", "form.fill", "form_input", "drag", "hover", "scroll", "scroll.top", "scroll.bottom", "scroll.to", "dialog.accept", "dialog.dismiss", "js", "eval"]);
319
+ function generatedClientPath(prefix, extension) {
320
+ return path.join(os.tmpdir(), `surf-${prefix}-${crypto.randomBytes(12).toString("hex")}${extension}`);
321
+ }
322
+
323
+ function scalarPath(value, field) {
324
+ if (typeof value !== "string" || !value) throw transferError(`${field} must be one path string`, "SURF_PATH_DESCRIPTOR");
325
+ return value;
326
+ }
327
+
328
+ function prepareRemoteTool(tool, args = {}) {
329
+ const prepared = { ...args };
330
+ const uploads = [];
331
+ const downloads = [];
332
+ const pathRefs = [];
333
+ const addPath = (field, value, kind) => {
334
+ const descriptor = parsePathDescriptor(scalarPath(value, field), { mode: "remote", field });
335
+ pathRefs.push({ field, kind, original: descriptor.original, path: descriptor.kind === "remote" ? descriptor.path : descriptor.original, pathKind: descriptor.kind });
336
+ if (descriptor.kind === "remote") prepared[field] = `remote:${descriptor.path}`;
337
+ return descriptor;
338
+ };
339
+ const addInput = (field, value) => {
340
+ const descriptor = addPath(field, value, "input");
341
+ if (descriptor.kind === "local") uploads.push({ path: descriptor.path, field, original: descriptor.original, transferId: randomId("upload") });
342
+ return descriptor;
343
+ };
344
+ const addOutput = (field, value) => {
345
+ const descriptor = addPath(field, value, "output");
346
+ if (descriptor.kind === "local") downloads.push({ transferId: randomId("download"), field, original: descriptor.original, destination: descriptor.path });
347
+ return descriptor;
348
+ };
349
+
350
+ if (args.autoScreenshot !== undefined && typeof args.autoScreenshot !== "boolean") throw transferError("autoScreenshot must be boolean", "SURF_PATH_DESCRIPTOR");
351
+ if (args.autoScreenshot === true && !AUTO_SCREENSHOT_TOOLS.includes(tool)) throw transferError(`autoScreenshot is not supported for ${tool}`, "SURF_PATH_DESCRIPTOR");
352
+ if (args.autoScreenshotOutput !== undefined) throw transferError("autoScreenshotOutput is internal", "SURF_PATH_DESCRIPTOR");
353
+ if (tool === "record") throw transferError("record is not supported with remote endpoint", "SURF_REMOTE_UNSUPPORTED");
354
+ if (tool === "aistudio.build") throw transferError("aistudio.build is not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
355
+ if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
356
+ if (tool === "network.export") {
357
+ if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
358
+ if (args.jsonl !== undefined && typeof args.jsonl !== "boolean") throw transferError("network.export jsonl must be boolean", "SURF_PATH_FIELD");
359
+ if (args.har === true && args.jsonl === true) throw transferError("network.export cannot combine --har and --jsonl", "SURF_PATH_FIELD");
360
+ const output = args.output === undefined
361
+ ? generatedClientPath("network-export", args.har === true ? ".har" : args.jsonl === true ? ".jsonl" : ".json")
362
+ : scalarPath(args.output, "output");
363
+ prepared.output = output;
364
+ addOutput("output", output);
365
+ }
366
+
367
+ if (tool === "chatgpt" && args.file !== undefined) {
368
+ addInput("file", scalarPath(args.file, "file"));
369
+ }
370
+
371
+ if (tool === "gemini") {
372
+ const hasFile = args.file !== undefined;
373
+ const hasEdit = args["edit-image"] !== undefined;
374
+ const hasGenerate = args["generate-image"] !== undefined;
375
+ const hasOutput = args.output !== undefined;
376
+ if (hasFile && (hasEdit || hasGenerate || hasOutput)) throw transferError("gemini attachment cannot combine with image mode or output", "SURF_REMOTE_UNSUPPORTED");
377
+ if (hasEdit && hasGenerate) throw transferError("gemini edit-image cannot combine with generate-image", "SURF_REMOTE_UNSUPPORTED");
378
+ if (hasGenerate && hasOutput) throw transferError("gemini generate-image uses its own output path", "SURF_REMOTE_UNSUPPORTED");
379
+ if (hasOutput && !hasEdit) throw transferError("gemini output requires edit-image", "SURF_REMOTE_UNSUPPORTED");
380
+ if (hasFile) addInput("file", scalarPath(args.file, "file"));
381
+ if (hasEdit) {
382
+ addInput("edit-image", scalarPath(args["edit-image"], "edit-image"));
383
+ const output = hasOutput ? scalarPath(args.output, "output") : "edited.png";
384
+ prepared.output = output;
385
+ addOutput("output", output);
386
+ } else if (hasGenerate) {
387
+ if (hasOutput) throw transferError("gemini generate-image uses its own output path", "SURF_REMOTE_UNSUPPORTED");
388
+ addOutput("generate-image", scalarPath(args["generate-image"], "generate-image"));
389
+ }
390
+ }
391
+
392
+ if (tool === "upload") {
393
+ const files = Array.isArray(args.files)
394
+ ? args.files
395
+ : typeof args.files === "string" ? args.files.split(",").map((value) => value.trim()).filter(Boolean) : [];
396
+ if (files.length !== 1) throw transferError("remote upload supports exactly one file", "SURF_REMOTE_UNSUPPORTED");
397
+ const descriptor = addInput("files", files[0]);
398
+ prepared.files = [descriptor.kind === "remote" ? `remote:${descriptor.path}` : files[0]];
399
+ }
400
+
401
+ if (tool === "screenshot") {
402
+ if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
403
+ if (args.savePath !== undefined || args.output !== undefined) {
404
+ const field = args.savePath !== undefined ? "savePath" : "output";
405
+ addOutput(field, args[field]);
406
+ }
407
+ }
408
+
409
+ if (args.autoScreenshot === true) {
410
+ const output = generatedClientPath("auto-screenshot", ".png");
411
+ prepared.autoScreenshotOutput = output;
412
+ addOutput("autoScreenshotOutput", output);
413
+ }
414
+
415
+ return { args: prepared, uploads, downloads, pathRefs };
416
+ }
417
+
418
+ function exactTransferObject(value, fields, label) {
419
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) throw transferError(`${label} must be an object`, "SURF_PATH_DESCRIPTOR");
420
+ const allowed = new Set(fields);
421
+ for (const key of Object.keys(value)) if (!allowed.has(key)) throw transferError(`${label} contains unsupported field ${key}`, "SURF_PATH_DESCRIPTOR");
422
+ for (const key of fields) if (!(key in value)) throw transferError(`${label} is missing ${key}`, "SURF_PATH_DESCRIPTOR");
423
+ return value;
424
+ }
425
+
426
+ async function materializeRemoteTool({ tool, args: rawArgs = {}, metadata = null, pathRefs = [], transferState, getTransferState } = {}) {
427
+ const args = { ...rawArgs };
428
+ if (args.autoScreenshot !== undefined && typeof args.autoScreenshot !== "boolean") throw transferError("autoScreenshot must be boolean", "SURF_PATH_DESCRIPTOR");
429
+ if (args.autoScreenshot === true && !AUTO_SCREENSHOT_TOOLS.includes(tool)) throw transferError(`autoScreenshot is not supported for ${tool}`, "SURF_PATH_DESCRIPTOR");
430
+ if (args.autoScreenshotOutput !== undefined && !(args.autoScreenshot === true && AUTO_SCREENSHOT_TOOLS.includes(tool))) throw transferError("autoScreenshotOutput is internal", "SURF_PATH_DESCRIPTOR");
431
+ const meta = metadata === undefined || metadata === null ? {} : exactTransferObject(metadata, ["uploads", "downloads"], "transfer metadata");
432
+ const uploads = meta.uploads === undefined ? [] : meta.uploads;
433
+ const downloads = meta.downloads === undefined ? [] : meta.downloads;
434
+ if (!Array.isArray(pathRefs) || !Array.isArray(uploads) || !Array.isArray(downloads) || uploads.length > 1 || downloads.length > 1) throw transferError("invalid transfer metadata", "SURF_PATH_DESCRIPTOR");
435
+ if (tool === "record" || tool === "aistudio.build") throw transferError(`${tool} is not supported for remote connections`, "SURF_REMOTE_UNSUPPORTED");
436
+ if (tool === "smoke" && args.screenshot !== undefined) throw transferError("smoke screenshots are not supported for remote connections", "SURF_REMOTE_UNSUPPORTED");
437
+ if (tool === "network.export") {
438
+ if (args.har !== undefined && typeof args.har !== "boolean") throw transferError("network.export har must be boolean", "SURF_PATH_FIELD");
439
+ if (args.jsonl !== undefined && typeof args.jsonl !== "boolean") throw transferError("network.export jsonl must be boolean", "SURF_PATH_FIELD");
440
+ if (args.har === true && args.jsonl === true) throw transferError("network.export cannot combine --har and --jsonl", "SURF_PATH_FIELD");
441
+ }
442
+
443
+ const descriptors = pathRefs.map((entry) => exactTransferObject(entry, ["field", "kind", "original", "path", "pathKind"], "path descriptor"));
444
+ const seenFields = new Set();
445
+ for (const descriptor of descriptors) {
446
+ if (typeof descriptor.field !== "string" || typeof descriptor.original !== "string" || !descriptor.original || typeof descriptor.path !== "string" || !descriptor.path) throw transferError("invalid path descriptor values", "SURF_PATH_DESCRIPTOR");
447
+ const parsed = parsePathDescriptor(descriptor.original, { mode: "remote", field: descriptor.field });
448
+ const expected = parsed.kind === "remote" ? parsed.path : descriptor.original;
449
+ if (seenFields.has(descriptor.field) || !["files", "file", "edit-image", "generate-image", "savePath", "output", "autoScreenshotOutput"].includes(descriptor.field) || !["input", "output"].includes(descriptor.kind) || descriptor.pathKind !== parsed.kind || descriptor.path !== expected) throw transferError("invalid or duplicate path descriptor", "SURF_PATH_DESCRIPTOR");
450
+ seenFields.add(descriptor.field);
451
+ }
452
+ const validateTransfer = (entry, fields, label) => {
453
+ exactTransferObject(entry, fields, label);
454
+ if (typeof entry.transferId !== "string" || !entry.transferId || entry.transferId.length > 128 || typeof entry.field !== "string" || typeof entry.original !== "string" || !entry.original || typeof entry.kind !== "string") throw transferError(`${label} contains invalid values`, "SURF_PATH_DESCRIPTOR");
455
+ };
456
+ uploads.forEach((entry) => validateTransfer(entry, ["transferId", "field", "original", "kind"], "upload descriptor"));
457
+ downloads.forEach((entry) => validateTransfer(entry, ["transferId", "field", "original", "kind"], "download descriptor"));
458
+
459
+ const pathFor = (field, kind) => descriptors.filter((entry) => entry.field === field && entry.kind === kind);
460
+ const rawMatches = (field, original) => field === "files"
461
+ ? Array.isArray(args[field]) && args[field].length === 1 && args[field][0] === original
462
+ : typeof args[field] === "string" && args[field] === original;
463
+ const outputTransfers = [];
464
+ const pathRewrites = [];
465
+ const transferCleanup = [];
466
+ let state = transferState;
467
+
468
+ const materializeInput = async (field) => {
469
+ const descriptor = descriptors.find((entry) => entry.field === field && entry.kind === "input");
470
+ if (!descriptor || !rawMatches(field, descriptor.original)) throw transferError(`${field} input descriptor mismatch`, "SURF_PATH_DESCRIPTOR");
471
+ if (descriptor.pathKind === "remote") {
472
+ if (uploads.length || descriptor.path !== parsePathDescriptor(descriptor.original, { mode: "remote", field }).path) throw transferError(`${field} remote descriptor mismatch`, "SURF_PATH_DESCRIPTOR");
473
+ await assertRegularFile(descriptor.path, field);
474
+ args[field] = descriptor.path;
475
+ pathRewrites.push({ path: descriptor.path, original: descriptor.original });
476
+ return descriptor;
477
+ }
478
+ const upload = uploads.find((entry) => entry.field === field && entry.kind === "upload" && entry.original === descriptor.original);
479
+ if (!upload || !state) throw transferError(`${field} upload is not complete`, "SURF_TRANSFER_UNKNOWN");
480
+ const completed = state.takeCompleted(upload.transferId);
481
+ if (!completed) throw transferError(`${field} upload is not complete`, "SURF_TRANSFER_UNKNOWN");
482
+ args[field] = completed.filePath;
483
+ pathRewrites.push({ path: completed.filePath, original: descriptor.original });
484
+ transferCleanup.push(completed.filePath);
485
+ return descriptor;
486
+ };
487
+ const materializeOutput = async (field) => {
488
+ const descriptor = descriptors.find((entry) => entry.field === field && entry.kind === "output");
489
+ if (!descriptor || !rawMatches(field, descriptor.original)) throw transferError(`${field} output descriptor mismatch`, "SURF_PATH_DESCRIPTOR");
490
+ if (descriptor.pathKind === "remote") {
491
+ const allowsGeminiInputOutput = tool === "gemini" && args["edit-image"] !== undefined;
492
+ if ((!allowsGeminiInputOutput && uploads.length) || downloads.length || descriptor.path !== parsePathDescriptor(descriptor.original, { mode: "remote", field }).path) throw transferError(`${field} remote descriptor mismatch`, "SURF_PATH_DESCRIPTOR");
493
+ args[field] = descriptor.path;
494
+ pathRewrites.push({ path: descriptor.path, original: descriptor.original });
495
+ return descriptor;
496
+ }
497
+ const download = downloads.find((entry) => entry.field === field && entry.kind === "download" && entry.original === descriptor.original);
498
+ const allowsGeminiInputOutput = tool === "gemini" && args["edit-image"] !== undefined;
499
+ if (!download || (!allowsGeminiInputOutput && uploads.length) || downloads.length !== 1) throw transferError(`${field} download descriptor mismatch`, "SURF_PATH_DESCRIPTOR");
500
+ state ||= await getTransferState();
501
+ const stagingPath = randomStagingPath(state.directory);
502
+ args[field] = stagingPath;
503
+ outputTransfers.push({ ...download, path: stagingPath });
504
+ pathRewrites.push({ path: stagingPath, original: descriptor.original });
505
+ transferCleanup.push(stagingPath);
506
+ return descriptor;
507
+ };
508
+
509
+ try {
510
+ if (tool === "upload") {
511
+ const files = args.files;
512
+ if (!Array.isArray(files) || files.length !== 1 || descriptors.length !== 1 || pathFor("files", "input").length !== 1 || downloads.length || !rawMatches("files", descriptors[0].original)) throw transferError("upload requires exactly one matching files input descriptor", "SURF_PATH_DESCRIPTOR");
513
+ await materializeInput("files");
514
+ } else if (tool === "chatgpt") {
515
+ if (args.file !== undefined) {
516
+ if (typeof args.file !== "string" || descriptors.length !== 1 || downloads.length) throw transferError("chatgpt attachment metadata mismatch", "SURF_PATH_DESCRIPTOR");
517
+ await materializeInput("file");
518
+ } else if (descriptors.length || uploads.length || downloads.length) throw transferError("chatgpt transfer metadata has no attachment", "SURF_PATH_DESCRIPTOR");
519
+ } else if (tool === "gemini") {
520
+ const hasFile = args.file !== undefined;
521
+ const hasEdit = args["edit-image"] !== undefined;
522
+ const hasGenerate = args["generate-image"] !== undefined;
523
+ const hasOutput = args.output !== undefined;
524
+ if (hasFile && (hasEdit || hasGenerate || hasOutput)) throw transferError("gemini attachment cannot combine with image mode or output", "SURF_REMOTE_UNSUPPORTED");
525
+ if (hasEdit && hasGenerate) throw transferError("gemini edit-image cannot combine with generate-image", "SURF_REMOTE_UNSUPPORTED");
526
+ if (hasGenerate && hasOutput) throw transferError("gemini generate-image uses its own output path", "SURF_REMOTE_UNSUPPORTED");
527
+ if (hasOutput && !hasEdit) throw transferError("gemini output requires edit-image", "SURF_REMOTE_UNSUPPORTED");
528
+ if (hasFile) {
529
+ if (descriptors.length !== 1 || downloads.length || pathFor("file", "input").length !== 1) throw transferError("gemini attachment metadata mismatch", "SURF_PATH_DESCRIPTOR");
530
+ await materializeInput("file");
531
+ } else if (hasEdit) {
532
+ if (descriptors.length !== 2 || pathFor("edit-image", "input").length !== 1 || pathFor("output", "output").length !== 1) throw transferError("gemini edit metadata mismatch", "SURF_PATH_DESCRIPTOR");
533
+ await materializeInput("edit-image");
534
+ await materializeOutput("output");
535
+ } else if (hasGenerate) {
536
+ if (descriptors.length !== 1 || pathFor("generate-image", "output").length !== 1) throw transferError("gemini generate metadata mismatch", "SURF_PATH_DESCRIPTOR");
537
+ await materializeOutput("generate-image");
538
+ }
539
+ else if (descriptors.length || uploads.length || downloads.length) throw transferError("gemini transfer metadata has no image path", "SURF_PATH_DESCRIPTOR");
540
+ } else if (tool === "screenshot") {
541
+ if (args.savePath !== undefined && args.output !== undefined) throw transferError("screenshot accepts only one output path", "SURF_PATH_FIELD");
542
+ const field = args.savePath !== undefined ? "savePath" : args.output !== undefined ? "output" : null;
543
+ if (field) {
544
+ if (descriptors.length !== 1) throw transferError("screenshot output metadata mismatch", "SURF_PATH_DESCRIPTOR");
545
+ await materializeOutput(field);
546
+ }
547
+ else if (descriptors.length || uploads.length || downloads.length) throw transferError("screenshot transfer metadata has no output path", "SURF_PATH_DESCRIPTOR");
548
+ } else if (tool === "network.export") {
549
+ if (typeof args.output !== "string" || descriptors.length !== 1 || pathFor("output", "output").length !== 1) throw transferError("network export output metadata mismatch", "SURF_PATH_DESCRIPTOR");
550
+ await materializeOutput("output");
551
+ } else if (AUTO_SCREENSHOT_TOOLS.includes(tool) && args.autoScreenshot === true) {
552
+ if (descriptors.length !== 1 || descriptors[0].field !== "autoScreenshotOutput" || descriptors[0].pathKind !== "local" || uploads.length) throw transferError("auto screenshot metadata mismatch", "SURF_PATH_DESCRIPTOR");
553
+ await materializeOutput("autoScreenshotOutput");
554
+ } else if (descriptors.length || uploads.length || downloads.length) {
555
+ throw transferError("file transfer metadata is not supported for this tool", "SURF_PATH_DESCRIPTOR");
556
+ }
557
+
558
+ if (uploads.length && !["upload", "chatgpt", "gemini"].includes(tool)) throw transferError("upload metadata is not supported for this tool", "SURF_PATH_DESCRIPTOR");
559
+ if (downloads.length && !["screenshot", "gemini", "network.export", ...AUTO_SCREENSHOT_TOOLS].includes(tool)) throw transferError("download metadata is not supported for this tool", "SURF_PATH_DESCRIPTOR");
560
+ return { args, transferState: state, outputTransfers, pathRewrites, transferCleanup };
561
+ } catch (error) {
562
+ await cleanupFilePaths([...new Set(transferCleanup)]);
563
+ throw error;
564
+ }
565
+ }
566
+
567
+ async function streamFileDownload({ writer, state, filePath, transferId = randomId("download"), original } = {}) {
568
+ const stats = await assertRegularFile(filePath, "download");
569
+ if (stats.size > DEFAULT_LIMITS.maxFileBytes) throw transferError("download exceeds file size limit", "SURF_TRANSFER_FILE_LIMIT");
570
+ state.reserveOutbound(stats.size, transferId);
571
+ let digest;
572
+ try {
573
+ digest = await hashFile(filePath);
574
+ if (digest.size !== stats.size || digest.size > DEFAULT_LIMITS.maxFileBytes) throw transferError("download changed during hashing", "SURF_TRANSFER_INTEGRITY");
575
+ } catch (error) {
576
+ state.releaseOutbound?.(stats.size);
577
+ throw error;
578
+ }
579
+ await writer.send({ type: "transfer_begin", version: TRANSFER_VERSION, direction: "download", transferId, size: digest.size, sha256: digest.sha256 });
580
+ let sequence = 0;
581
+ for await (const chunk of fs.createReadStream(filePath, { highWaterMark: DEFAULT_LIMITS.maxChunkBytes })) {
582
+ await writer.send({ type: "transfer_chunk", version: TRANSFER_VERSION, transferId, sequence, data: chunk.toString("base64") });
583
+ sequence += 1;
584
+ }
585
+ const completion = state.waitOutbound(transferId);
586
+ await writer.send({ type: "transfer_end", version: TRANSFER_VERSION, transferId });
587
+ await completion;
588
+ return { transferId, size: digest.size, sha256: digest.sha256 };
589
+ }
590
+
591
+ function createClientTransferController({ writer, limits = {}, onActivity = () => {} } = {}) {
592
+ const caps = { ...DEFAULT_LIMITS, ...limits };
593
+ const incoming = new Map();
594
+ const waiters = new Map();
595
+ let usedBytes = 0;
596
+ let fileCount = 0;
597
+ const seenIds = new Set();
598
+ let closed = false;
599
+ const waitFor = (id, type) => new Promise((resolve, reject) => {
600
+ const key = `${id}:${type}`;
601
+ waiters.set(key, { resolve, reject });
602
+ });
603
+ const settle = (frame) => {
604
+ const entry = waiters.get(`${frame.transferId}:${frame.type}`);
605
+ if (!entry) return false;
606
+ waiters.delete(`${frame.transferId}:${frame.type}`);
607
+ entry.resolve(frame);
608
+ return true;
609
+ };
610
+ const cancelDownload = async (transferId) => {
611
+ const state = incoming.get(transferId);
612
+ if (!state) return false;
613
+ incoming.delete(transferId);
614
+ await state.file?.close().catch(() => {});
615
+ await fsp.rm(state.temp, { force: true }).catch(() => {});
616
+ return true;
617
+ };
618
+ const cancelDownloads = async (ids) => {
619
+ for (const id of ids || [...incoming.keys()]) await cancelDownload(id);
620
+ };
621
+ const hasDownload = (id) => incoming.has(id);
622
+ const failDownload = async (id, error) => { await cancelDownload(id); throw error; };
623
+ const upload = async (filePath, { transferId = randomId("upload"), original, field } = {}) => {
624
+ if (closed) throw transferError("transfer connection is closed", "SURF_TRANSFER_CLOSED");
625
+ if (seenIds.has(transferId)) throw transferError("duplicate transfer ID", "SURF_TRANSFER_DUPLICATE");
626
+ seenIds.add(transferId);
627
+ const stats = await assertRegularFile(filePath, field || "upload");
628
+ if (stats.size > caps.maxFileBytes) throw transferError("upload exceeds file size limit", "SURF_TRANSFER_FILE_LIMIT");
629
+ if (fileCount >= caps.maxFiles || usedBytes + stats.size > caps.maxSessionBytes) throw transferError("transfer session limit exceeded", "SURF_TRANSFER_SESSION_LIMIT");
630
+ const digest = await hashFile(filePath);
631
+ usedBytes += digest.size; fileCount += 1;
632
+ const ready = waitFor(transferId, "transfer_ready");
633
+ await writer.send({ type: "transfer_begin", version: TRANSFER_VERSION, direction: "upload", transferId, size: digest.size, sha256: digest.sha256 });
634
+ await ready;
635
+ let sequence = 0;
636
+ for await (const chunk of fs.createReadStream(filePath, { highWaterMark: caps.maxChunkBytes })) {
637
+ await writer.send({ type: "transfer_chunk", version: TRANSFER_VERSION, transferId, sequence, data: chunk.toString("base64") });
638
+ sequence += 1;
639
+ onActivity();
640
+ }
641
+ const completeWait = waitFor(transferId, "transfer_complete");
642
+ await writer.send({ type: "transfer_end", version: TRANSFER_VERSION, transferId });
643
+ const complete = await completeWait;
644
+ return { transferId, size: digest.size, sha256: digest.sha256 };
645
+ };
646
+ const expectDownload = async ({ transferId, destination, size, sha256 }) => {
647
+ if (incoming.has(transferId) || seenIds.has(transferId)) throw transferError("duplicate transfer ID", "SURF_TRANSFER_DUPLICATE");
648
+ seenIds.add(transferId);
649
+ if (fileCount >= caps.maxFiles || (size !== undefined && (size > caps.maxFileBytes || usedBytes + size > caps.maxSessionBytes))) throw transferError("transfer session limit exceeded", "SURF_TRANSFER_SESSION_LIMIT");
650
+ const directory = path.dirname(destination);
651
+ await fsp.mkdir(directory, { recursive: true });
652
+ const temp = path.join(directory, `.${path.basename(destination)}.surf-${crypto.randomBytes(12).toString("hex")}.tmp`);
653
+ const file = await fsp.open(temp, "wx", 0o600);
654
+ incoming.set(transferId, { transferId, destination, size, sha256: sha256?.toLowerCase(), temp, file, hash: crypto.createHash("sha256"), sequence: 0, received: 0 });
655
+ fileCount += 1;
656
+ };
657
+ const handle = async (frame) => {
658
+ if (!frame || !TRANSFER_TYPES.has(frame.type)) return false;
659
+ if (frame.version !== TRANSFER_VERSION) throw transferError("unsupported transfer protocol version", "SURF_TRANSFER_PROTOCOL");
660
+ if (frame.type === "transfer_error") {
661
+ let found = false;
662
+ for (const [key, waiter] of waiters) {
663
+ if (key.startsWith(`${frame.transferId}:`)) { waiters.delete(key); waiter.reject(transferError(frame.error || "transfer failed")); found = true; }
664
+ }
665
+ if (!found) throw transferError("unknown transfer ID", "SURF_TRANSFER_UNKNOWN");
666
+ return true;
667
+ }
668
+ if (frame.type === "transfer_ready" || frame.type === "transfer_complete") {
669
+ if (!settle(frame)) throw transferError("unknown transfer ID", "SURF_TRANSFER_UNKNOWN");
670
+ return true;
671
+ }
672
+ if (frame.type === "transfer_begin") {
673
+ const state = incoming.get(frame.transferId);
674
+ if (!state || frame.direction !== "download" || (state.size !== undefined && frame.size !== state.size) || (state.sha256 && frame.sha256.toLowerCase() !== state.sha256)) return failDownload(frame.transferId, transferError("unknown or mismatched download", "SURF_TRANSFER_UNKNOWN"));
675
+ state.size = frame.size; state.sha256 = frame.sha256.toLowerCase();
676
+ if (usedBytes + state.size > caps.maxSessionBytes || state.size > caps.maxFileBytes) {
677
+ return failDownload(state.transferId, transferError("transfer session limit exceeded", "SURF_TRANSFER_SESSION_LIMIT"));
678
+ }
679
+ usedBytes += state.size;
680
+ state.started = true; onActivity(); return true;
681
+ }
682
+ const state = incoming.get(frame.transferId);
683
+ if (!state || !state.started) throw transferError("unknown transfer ID or state", "SURF_TRANSFER_UNKNOWN");
684
+ if (frame.type === "transfer_chunk") {
685
+ if (frame.sequence !== state.sequence) return failDownload(state.transferId, transferError("download chunks are out of order", "SURF_TRANSFER_SEQUENCE"));
686
+ let data;
687
+ try { data = decodeBase64(frame.data, caps.maxChunkBytes); } catch (error) { return failDownload(state.transferId, error); }
688
+ if (state.received + data.length > state.size) return failDownload(state.transferId, transferError("download exceeds declared size", "SURF_TRANSFER_SIZE"));
689
+ await state.file.write(data); state.hash.update(data); state.sequence += 1; state.received += data.length; onActivity(); return true;
690
+ }
691
+ if (frame.type === "transfer_end") {
692
+ const actualHash = state.hash.digest("hex");
693
+ if (state.received !== state.size || actualHash !== state.sha256) {
694
+ return failDownload(state.transferId, transferError(`download size or SHA-256 mismatch (received ${state.received}/${state.size})`, "SURF_TRANSFER_INTEGRITY"));
695
+ }
696
+ await state.file.close(); await fsp.chmod(state.temp, 0o600); await fsp.rename(state.temp, state.destination);
697
+ incoming.delete(state.transferId);
698
+ await writer.send({ type: "transfer_complete", version: TRANSFER_VERSION, transferId: state.transferId, size: state.size, sha256: state.sha256 });
699
+ onActivity(); return true;
700
+ }
701
+ throw transferError("unexpected transfer frame", "SURF_TRANSFER_PROTOCOL");
702
+ };
703
+ const cleanup = async (error = transferError("transfer connection closed", "SURF_TRANSFER_CLOSED")) => {
704
+ closed = true;
705
+ for (const entry of waiters.values()) entry.reject(error);
706
+ waiters.clear();
707
+ await cancelDownloads();
708
+ };
709
+ return { upload, expectDownload, cancelDownload, cancelDownloads, hasDownload, handle, cleanup };
710
+ }
711
+
712
+ module.exports = {
713
+ AUTO_SCREENSHOT_TOOLS,
714
+ DEFAULT_LIMITS,
715
+ TRANSFER_VERSION,
716
+ TRANSFER_TYPES,
717
+ assertRegularFile,
718
+ createStagingDirectory,
719
+ createTransferState,
720
+ createClientTransferController,
721
+ cleanupFilePaths,
722
+ decodeBase64,
723
+ streamFileDownload,
724
+ hashFile,
725
+ materializeRemoteTool,
726
+ parsePathDescriptor,
727
+ prepareRemoteTool,
728
+ randomStagingPath,
729
+ validateLocalToolPaths,
730
+ rewritePath,
731
+ rewriteTransferPaths,
732
+ transferError,
733
+ writeAtomicDownload,
734
+ };