surf-cli 2.8.0 → 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 (42) hide show
  1. package/README.md +98 -4
  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 +2 -2
  14. package/native/chatgpt-client.cjs +47 -31
  15. package/native/cli.cjs +300 -204
  16. package/native/client-transport.cjs +168 -0
  17. package/native/do-executor.cjs +25 -44
  18. package/native/doctor.cjs +55 -5
  19. package/native/endpoint.cjs +174 -0
  20. package/native/file-transfer.cjs +734 -0
  21. package/native/gemini-client.cjs +156 -71
  22. package/native/grok-client.cjs +98 -89
  23. package/native/host-helpers.cjs +37 -12
  24. package/native/host-sessions.cjs +283 -0
  25. package/native/host.cjs +800 -620
  26. package/native/listener.cjs +20 -0
  27. package/native/mcp-server.cjs +60 -65
  28. package/native/network-export.cjs +113 -0
  29. package/native/perplexity-client.cjs +46 -17
  30. package/native/remote-auth.cjs +279 -0
  31. package/native/remote-transport.cjs +337 -0
  32. package/native/request-pending.cjs +148 -0
  33. package/native/socket-path.cjs +1 -1
  34. package/package.json +8 -6
  35. package/scripts/install-native-host.cjs +36 -5
  36. package/skills/README.md +11 -5
  37. package/skills/deep-x-research/SKILL.md +106 -0
  38. package/skills/surf/SKILL.md +31 -4
  39. package/dist/content/accessibility-tree.js +0 -11
  40. package/dist/content/accessibility-tree.js.map +0 -1
  41. package/dist/content/visual-indicator.js +0 -111
  42. package/dist/content/visual-indicator.js.map +0 -1
@@ -0,0 +1,20 @@
1
+ const net = require("net");
2
+
3
+ function parseListenEndpoint(value) {
4
+ const v6 = typeof value === "string" && value.match(/^\[([^\]]+)\]:(\d+)$/);
5
+ const v4 = typeof value === "string" && value.match(/^([^:]+):(\d+)$/);
6
+ const host = v6 ? v6[1] : v4?.[1];
7
+ const port = Number(v6 ? v6[2] : v4?.[2]);
8
+ if (!host || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error("SURF_LISTEN must be a Tailnet IP and port 1..65535");
9
+ if (v6) {
10
+ if (net.isIP(host) !== 6) throw new Error("SURF_LISTEN must use a Tailscale IPv6 address");
11
+ const canonical = new URL(`http://[${host}]`).hostname.slice(1, -1);
12
+ if (!canonical.startsWith("fd7a:115c:a1e0:")) throw new Error("SURF_LISTEN must use a Tailscale IPv6 address");
13
+ return { host: canonical, port, display: `[${canonical}]:${port}` };
14
+ }
15
+ const parts = host.split(".").map(Number);
16
+ if (net.isIP(host) !== 4 || parts[0] !== 100 || parts[1] < 64 || parts[1] > 127) throw new Error("SURF_LISTEN must use a Tailscale IPv4 address");
17
+ return { host, port, display: `${host}:${port}` };
18
+ }
19
+
20
+ module.exports = { parseListenEndpoint };
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- const net = require("net");
3
2
  const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
4
3
  const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
5
4
  const { z } = require("zod");
6
- const { SOCKET_PATH, formatSocketError } = require("./socket-path.cjs");
7
-
8
- const REQUEST_TIMEOUT = 30000;
5
+ const { formatSocketError } = require("./socket-path.cjs");
6
+ const { selectEndpoint } = require("./endpoint.cjs");
7
+ const { openClientTransport } = require("./client-transport.cjs");
8
+ const { resolveRequestDeadlineMs } = require("./host-sessions.cjs");
9
+ const { prepareRemoteTool, validateLocalToolPaths } = require("./file-transfer.cjs");
9
10
 
10
11
  const TOOL_SCHEMAS = {
11
12
  navigate: {
@@ -263,63 +264,56 @@ const TOOL_SCHEMAS = {
263
264
  query: z.string().describe("Question about the page"),
264
265
  mode: z.enum(["find", "summary", "extract"]).optional().describe("Query mode")
265
266
  }
267
+ },
268
+ chatgpt: {
269
+ desc: "Ask ChatGPT through the browser session",
270
+ schema: {
271
+ query: z.string().describe("Question or prompt"),
272
+ model: z.string().optional().describe("ChatGPT model"),
273
+ "with-page": z.boolean().optional().describe("Include current page context"),
274
+ file: z.string().optional().describe("One attachment path"),
275
+ timeout: z.number().optional().describe("Timeout in seconds")
276
+ }
277
+ },
278
+ gemini: {
279
+ desc: "Ask Gemini or generate/edit one image",
280
+ schema: {
281
+ query: z.string().optional().describe("Question or image prompt"),
282
+ model: z.string().optional().describe("Gemini model"),
283
+ "with-page": z.boolean().optional().describe("Include current page context"),
284
+ file: z.string().optional().describe("One attachment path"),
285
+ "edit-image": z.string().optional().describe("One image input path"),
286
+ "generate-image": z.string().optional().describe("One generated image output path"),
287
+ output: z.string().optional().describe("Edited image output path"),
288
+ youtube: z.string().optional().describe("YouTube URL"),
289
+ "aspect-ratio": z.string().optional().describe("Image aspect ratio"),
290
+ timeout: z.number().optional().describe("Timeout in seconds")
291
+ }
292
+ },
293
+ "network.export": {
294
+ desc: "Export captured network requests",
295
+ schema: {
296
+ output: z.string().optional().describe("Output file path"),
297
+ jsonl: z.boolean().optional().describe("Write JSONL"),
298
+ har: z.boolean().optional().describe("Write HAR 1.2")
299
+ }
266
300
  }
267
301
  };
268
302
 
269
- function sendSocketRequest(tool, args = {}) {
270
- return new Promise((resolve, reject) => {
271
- const sock = net.createConnection(SOCKET_PATH, () => {
272
- const req = {
273
- type: "tool_request",
274
- method: "execute_tool",
275
- params: { tool, args },
276
- id: "mcp-" + Date.now() + "-" + Math.random()
277
- };
278
- sock.write(JSON.stringify(req) + "\n");
279
- });
280
-
281
- let buf = "";
282
- let settled = false;
283
- const timeout = setTimeout(() => {
284
- settled = true;
285
- sock.destroy();
286
- reject(new Error("Request timeout"));
287
- }, REQUEST_TIMEOUT);
288
-
289
- sock.on("data", (d) => {
290
- buf += d.toString();
291
- const lines = buf.split("\n");
292
- buf = lines.pop();
293
- for (const line of lines) {
294
- if (!line.trim()) continue;
295
- try {
296
- settled = true;
297
- clearTimeout(timeout);
298
- const resp = JSON.parse(line);
299
- sock.end();
300
- resolve(resp);
301
- } catch {
302
- settled = true;
303
- clearTimeout(timeout);
304
- sock.end();
305
- reject(new Error("Invalid JSON response"));
306
- }
307
- }
308
- });
309
-
310
- sock.on("error", (e) => {
311
- settled = true;
312
- clearTimeout(timeout);
313
- reject(new Error(formatSocketError(e)));
314
- });
315
-
316
- sock.on("close", () => {
317
- clearTimeout(timeout);
318
- if (!settled) {
319
- reject(new Error(`Socket closed unexpectedly\nAttempted socket: ${SOCKET_PATH}`));
320
- }
321
- });
322
- });
303
+ async function sendSocketRequest(tool, args = {}, endpoint = selectEndpoint([]).endpoint) {
304
+ const requestTimeoutMs = resolveRequestDeadlineMs(tool, args);
305
+ const transport = await openClientTransport(endpoint, { requestTimeoutMs });
306
+ try {
307
+ const prepared = endpoint.kind === "remote" ? prepareRemoteTool(tool, args) : (() => { const normalized = validateLocalToolPaths(tool, args); return { args: normalized, uploads: [], downloads: [] }; })();
308
+ return await transport.request({
309
+ type: "tool_request",
310
+ method: "execute_tool",
311
+ params: { tool, args: prepared.args },
312
+ id: "mcp-" + Date.now() + "-" + Math.random(),
313
+ }, requestTimeoutMs, prepared);
314
+ } finally {
315
+ await transport.close();
316
+ }
323
317
  }
324
318
 
325
319
  function formatResult(resp) {
@@ -351,7 +345,8 @@ function formatResult(resp) {
351
345
  }
352
346
 
353
347
  class PiChromeMcpServer {
354
- constructor() {
348
+ constructor(endpoint = selectEndpoint([]).endpoint) {
349
+ this.endpoint = endpoint;
355
350
  this.server = new McpServer({
356
351
  name: "surf",
357
352
  version: "1.0.0"
@@ -373,7 +368,7 @@ class PiChromeMcpServer {
373
368
  schemaObj,
374
369
  async (args) => {
375
370
  try {
376
- const resp = await sendSocketRequest(name, args);
371
+ const resp = await sendSocketRequest(name, args, this.endpoint);
377
372
  return formatResult(resp);
378
373
  } catch (err) {
379
374
  return {
@@ -392,7 +387,7 @@ class PiChromeMcpServer {
392
387
  "page://current",
393
388
  async (uri) => {
394
389
  try {
395
- const resp = await sendSocketRequest("page.read", {});
390
+ const resp = await sendSocketRequest("page.read", {}, this.endpoint);
396
391
  const text = resp.result?.content?.[0]?.text || "No content";
397
392
  return {
398
393
  contents: [{
@@ -418,7 +413,7 @@ class PiChromeMcpServer {
418
413
  "tabs://list",
419
414
  async (uri) => {
420
415
  try {
421
- const resp = await sendSocketRequest("tab.list", {});
416
+ const resp = await sendSocketRequest("tab.list", {}, this.endpoint);
422
417
  const text = resp.result?.content?.[0]?.text || "[]";
423
418
  return {
424
419
  contents: [{
@@ -444,7 +439,7 @@ class PiChromeMcpServer {
444
439
  "console://messages",
445
440
  async (uri) => {
446
441
  try {
447
- const resp = await sendSocketRequest("console", {});
442
+ const resp = await sendSocketRequest("console", {}, this.endpoint);
448
443
  const text = resp.result?.content?.[0]?.text || "No messages";
449
444
  return {
450
445
  contents: [{
@@ -470,7 +465,7 @@ class PiChromeMcpServer {
470
465
  "network://requests",
471
466
  async (uri) => {
472
467
  try {
473
- const resp = await sendSocketRequest("network", {});
468
+ const resp = await sendSocketRequest("network", {}, this.endpoint);
474
469
  const text = resp.result?.content?.[0]?.text || "No requests";
475
470
  return {
476
471
  contents: [{
@@ -511,4 +506,4 @@ if (require.main === module) {
511
506
  });
512
507
  }
513
508
 
514
- module.exports = { PiChromeMcpServer };
509
+ module.exports = { PiChromeMcpServer, TOOL_SCHEMAS };
@@ -0,0 +1,113 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { version: PACKAGE_VERSION } = require("../package.json");
5
+
6
+ const MAX_NETWORK_EXPORT_FILE_BYTES = 256 * 1024 * 1024;
7
+ const INTERNAL_FIELDS = new Set(["_requestId", "_responseReceived", "_loadingFinished"]);
8
+
9
+ function publicEntry(entry) {
10
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
11
+ throw new Error("network export entries must be objects");
12
+ }
13
+ const result = Object.create(null);
14
+ for (const [key, value] of Object.entries(entry)) {
15
+ if (!INTERNAL_FIELDS.has(key)) result[key] = value;
16
+ }
17
+ return result;
18
+ }
19
+
20
+ function headerList(headers) {
21
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return [];
22
+ return Object.entries(headers).map(([name, value]) => ({ name, value: String(value) }));
23
+ }
24
+
25
+ function harEntry(entry) {
26
+ const requestBody = entry.requestBody;
27
+ const responseBody = entry.responseBody;
28
+ const requestHeaders = entry.requestHeaders;
29
+ const responseHeaders = entry.responseHeaders;
30
+ const duration = Number.isFinite(entry.duration) ? Math.max(0, entry.duration) : 0;
31
+ const ttfb = Number.isFinite(entry.ttfb) ? Math.max(0, entry.ttfb) : duration;
32
+ return {
33
+ startedDateTime: new Date(Number(entry.ts) || Date.now()).toISOString(),
34
+ time: duration,
35
+ request: {
36
+ method: entry.method || "GET",
37
+ url: entry.url || "",
38
+ httpVersion: "HTTP/1.1",
39
+ headers: headerList(requestHeaders),
40
+ queryString: [],
41
+ cookies: [],
42
+ headersSize: -1,
43
+ bodySize: Number.isFinite(entry.requestBodySize) ? entry.requestBodySize : requestBody ? Buffer.byteLength(String(requestBody)) : -1,
44
+ ...(requestBody !== undefined ? { postData: { mimeType: "application/octet-stream", text: String(requestBody) } } : {}),
45
+ },
46
+ response: {
47
+ status: Number.isFinite(entry.status) ? entry.status : 0,
48
+ statusText: entry.statusText || "",
49
+ httpVersion: "HTTP/1.1",
50
+ headers: headerList(responseHeaders),
51
+ cookies: [],
52
+ content: {
53
+ size: Number.isFinite(entry.responseBodySize) ? entry.responseBodySize : responseBody ? Buffer.byteLength(String(responseBody)) : 0,
54
+ mimeType: entry.mimeType || "",
55
+ ...(responseBody !== undefined ? { text: String(responseBody) } : {}),
56
+ },
57
+ redirectURL: "",
58
+ headersSize: -1,
59
+ bodySize: Number.isFinite(entry.responseBodySize) ? entry.responseBodySize : responseBody ? Buffer.byteLength(String(responseBody)) : -1,
60
+ },
61
+ cache: {},
62
+ timings: { send: 0, wait: ttfb, receive: Math.max(0, duration - ttfb) },
63
+ ...(entry.comment ? { comment: String(entry.comment) } : {}),
64
+ };
65
+ }
66
+
67
+ function serializeNetworkExport(entries, format = "json") {
68
+ if (!Array.isArray(entries)) throw new Error("network export entries must be an array");
69
+ if (format !== "json" && format !== "jsonl" && format !== "har") throw new Error(`unsupported network export format: ${format}`);
70
+ const publicEntries = entries.map(publicEntry);
71
+ if (format === "jsonl") return `${publicEntries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
72
+ if (format === "har") {
73
+ return JSON.stringify({
74
+ log: {
75
+ version: "1.2",
76
+ creator: { name: "surf-cli", version: PACKAGE_VERSION },
77
+ entries: publicEntries.map(harEntry),
78
+ },
79
+ });
80
+ }
81
+ return JSON.stringify(publicEntries, null, 2);
82
+ }
83
+
84
+ function writeNetworkExport(outputPath, entries, format = "json") {
85
+ if (typeof outputPath !== "string" || !path.isAbsolute(outputPath)) throw new Error("network export output must be an absolute path");
86
+ const content = serializeNetworkExport(entries, format);
87
+ const bytes = Buffer.byteLength(content);
88
+ if (bytes > MAX_NETWORK_EXPORT_FILE_BYTES) throw new Error("network export exceeds the 256 MiB file limit");
89
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
90
+ const temporaryPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.surf-${crypto.randomBytes(12).toString("hex")}.tmp`);
91
+ try {
92
+ const fd = fs.openSync(temporaryPath, "wx", 0o600);
93
+ try {
94
+ fs.writeFileSync(fd, content, "utf8");
95
+ fs.fchmodSync(fd, 0o600);
96
+ } finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ fs.renameSync(temporaryPath, outputPath);
100
+ } catch (error) {
101
+ try { fs.rmSync(temporaryPath, { force: true }); } catch {}
102
+ throw error;
103
+ }
104
+ return { path: outputPath, format, count: entries.length, bytes };
105
+ }
106
+
107
+ module.exports = {
108
+ INTERNAL_FIELDS,
109
+ MAX_NETWORK_EXPORT_FILE_BYTES,
110
+ publicEntry,
111
+ serializeNetworkExport,
112
+ writeNetworkExport,
113
+ };
@@ -5,14 +5,16 @@
5
5
  * Similar approach to the ChatGPT client.
6
6
  */
7
7
 
8
+ const { abortableDelay, raceAbort, throwIfAborted } = require("./abort.cjs");
9
+
8
10
  const PERPLEXITY_URL = "https://www.perplexity.ai/";
9
11
 
10
12
  // ============================================================================
11
13
  // Helpers
12
14
  // ============================================================================
13
15
 
14
- function delay(ms) {
15
- return new Promise(resolve => setTimeout(resolve, ms));
16
+ function delay(ms, signal) {
17
+ return abortableDelay(ms, signal);
16
18
  }
17
19
 
18
20
  function buildClickDispatcher() {
@@ -371,7 +373,27 @@ async function submitPrompt(cdp, inputCdp) {
371
373
  // Response Handling
372
374
  // ============================================================================
373
375
 
374
- async function waitForResponse(cdp, timeoutMs = 120000) {
376
+ function extractPerplexityResponseText() {
377
+ const selectors = [
378
+ '[id^="markdown-content"]',
379
+ '[data-testid="answer"]',
380
+ 'article',
381
+ '.prose',
382
+ ];
383
+
384
+ for (const selector of selectors) {
385
+ const elements = Array.from(document.querySelectorAll(selector));
386
+ for (let i = elements.length - 1; i >= 0; i--) {
387
+ const text = elements[i].innerText?.trim() || '';
388
+ if (text) return text;
389
+ }
390
+ }
391
+
392
+ return '';
393
+ }
394
+
395
+ async function waitForResponse(cdp, timeoutMs = 120000, signal) {
396
+ throwIfAborted(signal);
375
397
  const deadline = Date.now() + timeoutMs;
376
398
  let previousText = '';
377
399
  let stableCycles = 0;
@@ -386,17 +408,16 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
386
408
  if (url && url.includes('/search/')) {
387
409
  break;
388
410
  }
389
- await delay(200);
411
+ await delay(200, signal);
390
412
  }
391
413
 
392
414
  // Wait a bit for the response area to render
393
- await delay(1000);
415
+ await delay(1000, signal);
394
416
 
395
417
  // Now poll for response completion
396
418
  while (Date.now() < deadline) {
397
419
  const snapshot = await evaluate(cdp, `(function() {
398
- const prose = document.querySelector('.prose');
399
- const text = prose ? prose.innerText : '';
420
+ const text = (${extractPerplexityResponseText.toString()})();
400
421
  const hasStop = !!document.querySelector('button[aria-label*=stop], button[aria-label*=Stop]');
401
422
  const hasCopy = !!document.querySelector('button[aria-label*=copy], button[aria-label*=Copy]');
402
423
  const hasRelated = document.body.innerText.indexOf('Related') > -1;
@@ -412,7 +433,7 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
412
433
  })()`);
413
434
 
414
435
  if (!snapshot) {
415
- await delay(300);
436
+ await delay(300, signal);
416
437
  continue;
417
438
  }
418
439
 
@@ -437,7 +458,7 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
437
458
  const hasCompletionIndicators = snapshot.hasActions || snapshot.hasRelated || snapshot.hasFollowUp;
438
459
  const isDone = !snapshot.generating && (hasCompletionIndicators || isStable);
439
460
 
440
- if (isDone && currentText.length > 20) {
461
+ if (isDone && currentText.trim().length > 0) {
441
462
  // Clean up the response text
442
463
  let cleanText = currentText;
443
464
 
@@ -454,11 +475,11 @@ async function waitForResponse(cdp, timeoutMs = 120000) {
454
475
  };
455
476
  }
456
477
 
457
- await delay(300);
478
+ await delay(300, signal);
458
479
  }
459
480
 
460
481
  // Timeout - return whatever we have
461
- if (previousText.length > 20) {
482
+ if (previousText.trim().length > 0) {
462
483
  return {
463
484
  text: previousText,
464
485
  sources: 0,
@@ -485,13 +506,15 @@ async function query(options) {
485
506
  cdpEvaluate,
486
507
  cdpCommand,
487
508
  log = () => {},
509
+ signal,
488
510
  } = options;
511
+ throwIfAborted(signal);
489
512
 
490
513
  const startTime = Date.now();
491
514
  log("Starting Perplexity query");
492
515
 
493
516
  // Create tab
494
- const tabInfo = await createTab();
517
+ const tabInfo = await raceAbort(createTab, signal);
495
518
  log(`createTab returned: ${JSON.stringify(tabInfo)}`);
496
519
  const { tabId } = tabInfo || {};
497
520
 
@@ -500,8 +523,8 @@ async function query(options) {
500
523
  }
501
524
  log(`Created tab ${tabId}`);
502
525
 
503
- const cdp = (expr) => cdpEvaluate(tabId, expr);
504
- const inputCdp = (method, params) => cdpCommand(tabId, method, params);
526
+ const cdp = (expr) => raceAbort(() => cdpEvaluate(tabId, expr), signal);
527
+ const inputCdp = (method, params) => raceAbort(() => cdpCommand(tabId, method, params), signal);
505
528
 
506
529
  try {
507
530
  // Wait for page load
@@ -522,6 +545,7 @@ async function query(options) {
522
545
  const selectedMode = await selectMode(cdp, mode);
523
546
  log(`Mode: ${selectedMode}`);
524
547
  } catch (e) {
548
+ if (signal?.aborted) throw e;
525
549
  log(`Mode selection failed: ${e.message}`);
526
550
  }
527
551
  }
@@ -532,6 +556,7 @@ async function query(options) {
532
556
  const selectedModel = await selectModel(cdp, model);
533
557
  log(`Model: ${selectedModel}`);
534
558
  } catch (e) {
559
+ if (signal?.aborted) throw e;
535
560
  log(`Model selection failed: ${e.message}`);
536
561
  }
537
562
  }
@@ -545,7 +570,7 @@ async function query(options) {
545
570
  log("Submitted, waiting for response...");
546
571
 
547
572
  // Wait for response
548
- const response = await waitForResponse(cdp, timeout);
573
+ const response = await waitForResponse(cdp, timeout, signal);
549
574
  log(`Response: ${response.text.length} chars, ${response.sources} sources${response.partial ? ' (partial)' : ''}`);
550
575
 
551
576
  return {
@@ -558,8 +583,12 @@ async function query(options) {
558
583
  tookMs: Date.now() - startTime,
559
584
  };
560
585
  } finally {
561
- await closeTab(tabId).catch(() => {});
586
+ try {
587
+ await closeTab(tabId);
588
+ } catch (error) {
589
+ log(`Failed to close Perplexity tab ${tabId}: ${error?.message || error}`);
590
+ }
562
591
  }
563
592
  }
564
593
 
565
- module.exports = { query, PERPLEXITY_URL };
594
+ module.exports = { query, PERPLEXITY_URL, waitForResponse, extractPerplexityResponseText };