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,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
-
7
- const SOCKET_PATH = process.platform === "win32" ? "//./pipe/surf" : "/tmp/surf.sock";
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,60 +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
- const timeout = setTimeout(() => {
283
- sock.destroy();
284
- reject(new Error("Request timeout"));
285
- }, REQUEST_TIMEOUT);
286
-
287
- sock.on("data", (d) => {
288
- buf += d.toString();
289
- const lines = buf.split("\n");
290
- buf = lines.pop();
291
- for (const line of lines) {
292
- if (!line.trim()) continue;
293
- try {
294
- clearTimeout(timeout);
295
- const resp = JSON.parse(line);
296
- sock.end();
297
- resolve(resp);
298
- } catch {
299
- clearTimeout(timeout);
300
- sock.end();
301
- reject(new Error("Invalid JSON response"));
302
- }
303
- }
304
- });
305
-
306
- sock.on("error", (e) => {
307
- clearTimeout(timeout);
308
- if (e.code === "ENOENT") {
309
- reject(new Error("Socket not found. Is Chrome running with the surf extension?"));
310
- } else {
311
- reject(e);
312
- }
313
- });
314
-
315
- sock.on("close", () => {
316
- clearTimeout(timeout);
317
- reject(new Error("Socket closed unexpectedly"));
318
- });
319
- });
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
+ }
320
317
  }
321
318
 
322
319
  function formatResult(resp) {
@@ -348,7 +345,8 @@ function formatResult(resp) {
348
345
  }
349
346
 
350
347
  class PiChromeMcpServer {
351
- constructor() {
348
+ constructor(endpoint = selectEndpoint([]).endpoint) {
349
+ this.endpoint = endpoint;
352
350
  this.server = new McpServer({
353
351
  name: "surf",
354
352
  version: "1.0.0"
@@ -370,7 +368,7 @@ class PiChromeMcpServer {
370
368
  schemaObj,
371
369
  async (args) => {
372
370
  try {
373
- const resp = await sendSocketRequest(name, args);
371
+ const resp = await sendSocketRequest(name, args, this.endpoint);
374
372
  return formatResult(resp);
375
373
  } catch (err) {
376
374
  return {
@@ -389,7 +387,7 @@ class PiChromeMcpServer {
389
387
  "page://current",
390
388
  async (uri) => {
391
389
  try {
392
- const resp = await sendSocketRequest("page.read", {});
390
+ const resp = await sendSocketRequest("page.read", {}, this.endpoint);
393
391
  const text = resp.result?.content?.[0]?.text || "No content";
394
392
  return {
395
393
  contents: [{
@@ -415,7 +413,7 @@ class PiChromeMcpServer {
415
413
  "tabs://list",
416
414
  async (uri) => {
417
415
  try {
418
- const resp = await sendSocketRequest("tab.list", {});
416
+ const resp = await sendSocketRequest("tab.list", {}, this.endpoint);
419
417
  const text = resp.result?.content?.[0]?.text || "[]";
420
418
  return {
421
419
  contents: [{
@@ -441,7 +439,7 @@ class PiChromeMcpServer {
441
439
  "console://messages",
442
440
  async (uri) => {
443
441
  try {
444
- const resp = await sendSocketRequest("console", {});
442
+ const resp = await sendSocketRequest("console", {}, this.endpoint);
445
443
  const text = resp.result?.content?.[0]?.text || "No messages";
446
444
  return {
447
445
  contents: [{
@@ -467,7 +465,7 @@ class PiChromeMcpServer {
467
465
  "network://requests",
468
466
  async (uri) => {
469
467
  try {
470
- const resp = await sendSocketRequest("network", {});
468
+ const resp = await sendSocketRequest("network", {}, this.endpoint);
471
469
  const text = resp.result?.content?.[0]?.text || "No requests";
472
470
  return {
473
471
  contents: [{
@@ -508,4 +506,4 @@ if (require.main === module) {
508
506
  });
509
507
  }
510
508
 
511
- 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 };