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,148 @@
1
+ const { abortError } = require("./abort.cjs");
2
+
3
+ class RequestPendingMap extends Map {
4
+ constructor({ getRequest = () => undefined } = {}) {
5
+ super();
6
+ this.getRequest = getRequest;
7
+ this.drainWaiters = new Map();
8
+ }
9
+
10
+ set(id, data) {
11
+ const request = data.request || this.getRequest();
12
+ const cleanup = Boolean(data.cleanup || data.tool === "close_tab");
13
+ const entry = { ...data, id, request, cleanup, aborted: false, settled: false };
14
+ if (request) {
15
+ if (!request.pendingEntries) request.pendingEntries = new Set();
16
+ request.pendingEntries.add(entry);
17
+ if (cleanup) entry.abortCleanup = null;
18
+ else {
19
+ const onAbort = () => {
20
+ if (entry.abortNotified || entry.settled) return;
21
+ entry.abortNotified = true;
22
+ entry.aborted = true;
23
+ const error = abortError(request.signal);
24
+ if (entry.reject) entry.reject(error);
25
+ else entry.onAbort?.(error);
26
+ if (!entry.reject && !entry.onAbort) entry.onComplete?.({ error: error.message, cancelled: true });
27
+ };
28
+ entry.abortCleanup = () => request.signal.removeEventListener("abort", onAbort);
29
+ request.signal.addEventListener("abort", onAbort, { once: true });
30
+ if (request.signal.aborted) onAbort();
31
+ }
32
+ }
33
+ super.set(id, entry);
34
+ return this;
35
+ }
36
+
37
+ get(id) {
38
+ return super.get(id);
39
+ }
40
+
41
+ delete(id) {
42
+ const entry = super.get(id);
43
+ if (!entry) return false;
44
+ this.#removeEntry(entry);
45
+ return true;
46
+ }
47
+
48
+ #removeEntry(entry, notify = true) {
49
+ super.delete(entry.id);
50
+ entry.abortCleanup?.();
51
+ if (entry.tombstoneTimer) clearTimeout(entry.tombstoneTimer);
52
+ entry.request?.pendingEntries?.delete(entry);
53
+ if (notify) this.#notifyDrain(entry.request);
54
+ }
55
+
56
+ #notifyDrain(request) {
57
+ if (!request || request.pendingEntries?.size) return;
58
+ const waiters = this.drainWaiters.get(request);
59
+ if (!waiters) return;
60
+ this.drainWaiters.delete(request);
61
+ for (const waiter of waiters) waiter();
62
+ }
63
+
64
+ onDrain(request, callback) {
65
+ if (!request?.pendingEntries?.size) {
66
+ callback();
67
+ return;
68
+ }
69
+ const waiters = this.drainWaiters.get(request) || [];
70
+ waiters.push(callback);
71
+ this.drainWaiters.set(request, waiters);
72
+ }
73
+
74
+ resolve(id, value) {
75
+ const entry = this.get(id);
76
+ if (!entry) return false;
77
+ this.#removeEntry(entry, false);
78
+ try {
79
+ if (!entry.aborted && !entry.hardBoundary && !entry.settled) {
80
+ entry.settled = true;
81
+ if (entry.resolve) entry.resolve(value);
82
+ else if (entry.onComplete) entry.onComplete(value);
83
+ }
84
+ } finally {
85
+ this.#notifyDrain(entry.request);
86
+ }
87
+ return true;
88
+ }
89
+
90
+ expire(id, error) {
91
+ const entry = this.get(id);
92
+ if (!entry) return false;
93
+ if (!entry.settled) {
94
+ entry.settled = true;
95
+ entry.aborted = true;
96
+ entry.reject?.(error);
97
+ const request = entry.request;
98
+ const remaining = request ? Math.max(0, request.startedAt + request.deadlineMs - Date.now()) : 0;
99
+ entry.tombstoneTimer = setTimeout(() => this.delete(entry.id), remaining);
100
+ }
101
+ return true;
102
+ }
103
+
104
+ reject(id, error) {
105
+ const entry = this.get(id);
106
+ if (!entry) return false;
107
+ this.#removeEntry(entry, false);
108
+ try {
109
+ if (!entry.settled) {
110
+ entry.settled = true;
111
+ entry.reject?.(error);
112
+ }
113
+ } finally {
114
+ this.#notifyDrain(entry.request);
115
+ }
116
+ return true;
117
+ }
118
+
119
+ tombstoneAfterAbort(request) {
120
+ if (!request?.pendingEntries) return;
121
+ for (const entry of request.pendingEntries) {
122
+ if (entry.cleanup || entry.tombstoneTimer) continue;
123
+ const remaining = Math.max(0, request.startedAt + request.deadlineMs - Date.now());
124
+ entry.tombstoneTimer = setTimeout(() => this.delete(entry.id), remaining);
125
+ }
126
+ }
127
+
128
+ hardDeadline(request) {
129
+ if (!request) return;
130
+ request.hardBoundary = true;
131
+ for (const entry of [...(request.pendingEntries || [])]) {
132
+ entry.hardBoundary = true;
133
+ this.#removeEntry(entry);
134
+ if (!entry.settled) {
135
+ entry.settled = true;
136
+ entry.reject?.(abortError(request.signal, "Request timed out"));
137
+ }
138
+ }
139
+ }
140
+
141
+ clear() {
142
+ for (const entry of this.values()) this.#removeEntry(entry);
143
+ this.drainWaiters.clear();
144
+ super.clear();
145
+ }
146
+ }
147
+
148
+ module.exports = { RequestPendingMap };
@@ -0,0 +1,46 @@
1
+ const path = require("path");
2
+ const os = require("os");
3
+
4
+ const IS_WIN = process.platform === "win32";
5
+ const DEFAULT_SOCKET_PATH = IS_WIN ? "//./pipe/surf" : "/tmp/surf.sock";
6
+ const SOCKET_PATH = process.env.SURF_SOCKET || DEFAULT_SOCKET_PATH;
7
+ const SURF_TMP = process.env.SURF_TMP || (IS_WIN ? path.join(os.tmpdir(), "surf") : "/tmp");
8
+
9
+ function getSocketTroubleshootingHint() {
10
+ const lines = [
11
+ `Attempted socket: ${SOCKET_PATH}`,
12
+ "Make sure the browser is running with the Surf extension enabled, then restart the browser after native host install changes.",
13
+ "Run `surf doctor --browser all` for detailed native host diagnostics.",
14
+ ];
15
+
16
+ if (process.env.SURF_SOCKET) {
17
+ lines.push("SURF_SOCKET is set; make sure the native host and CLI use the same value.");
18
+ }
19
+
20
+ if (process.platform === "linux") {
21
+ lines.push("On WSL2 with Windows Chrome, run `surf install <extension-id>` from WSL and restart Windows Chrome.");
22
+ }
23
+
24
+ return lines.join("\n");
25
+ }
26
+
27
+ function formatSocketError(error, context = "connect") {
28
+ let message;
29
+ if (error && error.code === "ENOENT") {
30
+ message = "Socket not found.";
31
+ } else if (error && error.code === "ECONNREFUSED") {
32
+ message = "Connection refused. Native host is not accepting connections.";
33
+ } else {
34
+ message = error && error.message ? error.message : String(error);
35
+ }
36
+
37
+ return `Socket ${context} failed: ${message}\n${getSocketTroubleshootingHint()}`;
38
+ }
39
+
40
+ module.exports = {
41
+ DEFAULT_SOCKET_PATH,
42
+ SOCKET_PATH,
43
+ SURF_TMP,
44
+ formatSocketError,
45
+ getSocketTroubleshootingHint,
46
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surf-cli",
3
- "version": "2.7.2",
3
+ "version": "2.9.0",
4
4
  "description": "CLI for AI agents to control Chrome. Zero config, agent-agnostic, battle-tested.",
5
5
  "keywords": [
6
6
  "chrome",
@@ -43,6 +43,7 @@
43
43
  "lint:test": "biome check test/",
44
44
  "format": "biome format --write .",
45
45
  "test": "vitest run",
46
+ "test:e2e:chrome": "node test/e2e/real-chrome.mjs",
46
47
  "test:watch": "vitest",
47
48
  "test:coverage": "vitest run --coverage",
48
49
  "test:ui": "vitest --ui",
@@ -56,16 +57,17 @@
56
57
  "crypto-browserify": "^3.12.1",
57
58
  "events": "^3.3.0",
58
59
  "stream-browserify": "^3.0.0",
59
- "vite-plugin-node-polyfills": "^0.25.0",
60
+ "vite-plugin-node-polyfills": "^0.28.0",
60
61
  "zod": "^4.3.6"
61
62
  },
62
63
  "devDependencies": {
63
- "@biomejs/biome": "^2.4.4",
64
- "@types/chrome": "^0.1.37",
65
- "@vitest/coverage-v8": "^4.0.18",
66
- "@vitest/ui": "^4.0.18",
67
- "typescript": "^5.7.2",
68
- "vite": "^7.3.1",
69
- "vitest": "^4.0.18"
64
+ "@biomejs/biome": "^2.5.4",
65
+ "@types/chrome": "^0.2.2",
66
+ "@vitest/coverage-v8": "^4.1.9",
67
+ "@vitest/ui": "^4.1.9",
68
+ "puppeteer": "25.3.0",
69
+ "typescript": "^7.0.2",
70
+ "vite": "^8.1.4",
71
+ "vitest": "^4.1.9"
70
72
  }
71
73
  }
@@ -2,7 +2,9 @@
2
2
  const fs = require("fs");
3
3
  const path = require("path");
4
4
  const os = require("os");
5
- const { execSync, spawnSync } = require("child_process");
5
+ const { execFileSync, execSync } = require("child_process");
6
+ const { parseListenEndpoint } = require("../native/listener.cjs");
7
+ const { getStateDir, loadHostIdentity, loadRegistry } = require("../native/remote-auth.cjs");
6
8
 
7
9
  const HOST_NAME = "surf.browser.host";
8
10
 
@@ -12,12 +14,14 @@ const BROWSERS = {
12
14
  darwin: "Library/Application Support/Google/Chrome/NativeMessagingHosts",
13
15
  linux: ".config/google-chrome/NativeMessagingHosts",
14
16
  win32: "Google\\Chrome",
17
+ wsl: "Google/Chrome/User Data/NativeMessagingHosts",
15
18
  },
16
19
  chromium: {
17
20
  name: "Chromium",
18
21
  darwin: "Library/Application Support/Chromium/NativeMessagingHosts",
19
22
  linux: ".config/chromium/NativeMessagingHosts",
20
23
  win32: "Chromium",
24
+ wsl: "Chromium/User Data/NativeMessagingHosts",
21
25
  },
22
26
  brave: {
23
27
  name: "Brave",
@@ -25,12 +29,14 @@ const BROWSERS = {
25
29
  "Library/Application Support/BraveSoftware/Brave-Browser/NativeMessagingHosts",
26
30
  linux: ".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",
27
31
  win32: "BraveSoftware\\Brave-Browser",
32
+ wsl: "BraveSoftware/Brave-Browser/User Data/NativeMessagingHosts",
28
33
  },
29
34
  edge: {
30
35
  name: "Microsoft Edge",
31
36
  darwin: "Library/Application Support/Microsoft Edge/NativeMessagingHosts",
32
37
  linux: ".config/microsoft-edge/NativeMessagingHosts",
33
38
  win32: "Microsoft\\Edge",
39
+ wsl: "Microsoft/Edge/User Data/NativeMessagingHosts",
34
40
  },
35
41
  arc: {
36
42
  name: "Arc",
@@ -38,12 +44,14 @@ const BROWSERS = {
38
44
  "Library/Application Support/Arc/User Data/NativeMessagingHosts",
39
45
  linux: null,
40
46
  win32: null,
47
+ wsl: null,
41
48
  },
42
49
  helium: {
43
50
  name: "Helium",
44
51
  darwin: "Library/Application Support/net.imput.helium/NativeMessagingHosts",
45
52
  linux: null,
46
53
  win32: null,
54
+ wsl: null,
47
55
  },
48
56
  };
49
57
 
@@ -63,6 +71,16 @@ const NODE_PATHS = {
63
71
  ],
64
72
  };
65
73
 
74
+ function isWsl() {
75
+ if (process.platform !== "linux") return false;
76
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) return true;
77
+ try {
78
+ return /microsoft|wsl/i.test(fs.readFileSync("/proc/version", "utf8"));
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
66
84
  function findNode() {
67
85
  if (process.env.SURF_NODE_PATH && fs.existsSync(process.env.SURF_NODE_PATH)) {
68
86
  return process.env.SURF_NODE_PATH;
@@ -88,10 +106,14 @@ function findNpmGlobalRoot() {
88
106
  }
89
107
  }
90
108
 
91
- function getWrapperDir() {
92
- const platform = process.platform;
109
+ function getWrapperDir(target = process.platform) {
93
110
  const home = os.homedir();
94
- switch (platform) {
111
+ if (target === "wsl-windows") {
112
+ const localAppData = getWindowsEnv("LOCALAPPDATA");
113
+ if (!localAppData) return null;
114
+ return path.join(windowsPathToWslPath(localAppData), "surf-cli");
115
+ }
116
+ switch (process.platform) {
95
117
  case "darwin":
96
118
  return path.join(home, "Library/Application Support/surf-cli");
97
119
  case "linux":
@@ -117,72 +139,129 @@ function getHostPath() {
117
139
  return null;
118
140
  }
119
141
 
120
- function createWrapper(wrapperDir, nodePath, hostPath) {
121
- const platform = process.platform;
142
+ function getWindowsEnv(name) {
143
+ try {
144
+ return execFileSync("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
145
+ .trim()
146
+ .replace(/\r/g, "");
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function windowsPathToWslPath(winPath) {
153
+ const normalized = winPath.replace(/\\/g, "/");
154
+ const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
155
+ if (!match) return normalized;
156
+ return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
157
+ }
158
+
159
+ function wslPathToWindowsPath(wslPath) {
160
+ try {
161
+ return execFileSync("wslpath", ["-w", wslPath], { encoding: "utf8" }).trim().replace(/\r/g, "");
162
+ } catch {
163
+ const match = wslPath.match(/^\/mnt\/([a-zA-Z])\/(.*)$/);
164
+ if (match) return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, "\\")}`;
165
+ return wslPath;
166
+ }
167
+ }
168
+
169
+ function createWrapper(wrapperDir, nodePath, hostPath, target = process.platform, listen) {
122
170
  fs.mkdirSync(wrapperDir, { recursive: true });
123
171
 
124
- if (platform === "win32") {
172
+ if (target === "wsl-windows") {
173
+ const cmdPath = path.join(wrapperDir, "host-wrapper-wsl.cmd");
174
+ const distroArg = process.env.WSL_DISTRO_NAME ? ` -d "${process.env.WSL_DISTRO_NAME}"` : "";
175
+ const content = `@echo off\r\nwsl.exe${distroArg} --cd "${path.dirname(hostPath)}" --exec "${nodePath}" "${hostPath}" %*\r\n`;
176
+ fs.writeFileSync(cmdPath, content);
177
+ return wslPathToWindowsPath(cmdPath);
178
+ }
179
+
180
+ if (process.platform === "win32") {
125
181
  const batPath = path.join(wrapperDir, "host-wrapper.bat");
126
- const content = `@echo off\r\n"${nodePath}" "${hostPath}"\r\n`;
182
+ const content = `@echo off\r\n"${nodePath}" "${hostPath}" %*\r\n`;
127
183
  fs.writeFileSync(batPath, content);
128
184
  return batPath;
129
- } else {
130
- const shPath = path.join(wrapperDir, "host-wrapper.sh");
131
- const hostDir = path.dirname(hostPath);
132
- const content = `#!/bin/bash
185
+ }
186
+
187
+ const shPath = path.join(wrapperDir, "host-wrapper.sh");
188
+ const hostDir = path.dirname(hostPath);
189
+ const content = `#!/usr/bin/env bash
133
190
  cd "${hostDir}"
134
- exec "${nodePath}" "${hostPath}"
191
+ ${listen ? `: "\${SURF_LISTEN:=${listen}}"\nexport SURF_LISTEN\n` : ""}exec "${nodePath}" "${hostPath}" "$@"
135
192
  `;
136
- fs.writeFileSync(shPath, content);
137
- fs.chmodSync(shPath, "755");
138
- return shPath;
139
- }
193
+ fs.writeFileSync(shPath, content);
194
+ fs.chmodSync(shPath, "755");
195
+ return shPath;
140
196
  }
141
197
 
142
- function installManifest(browser, extensionId, wrapperPath) {
143
- const platform = process.platform;
144
- const browserConfig = BROWSERS[browser];
145
-
146
- if (!browserConfig || !browserConfig[platform]) {
147
- return null;
198
+ function assertListenTargetSupported(listen, target) {
199
+ if (listen && (target === "win32" || target === "wsl-windows")) {
200
+ throw new Error("--listen is not supported for Windows native-host wrappers");
148
201
  }
202
+ }
149
203
 
150
- if (platform === "win32") {
151
- return installWindowsRegistry(browser, extensionId, wrapperPath);
152
- }
204
+ function readExistingManifest(manifestPath) {
205
+ if (!fs.existsSync(manifestPath)) return {};
206
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
207
+ }
153
208
 
154
- const manifestDir = path.join(os.homedir(), browserConfig[platform]);
155
- fs.mkdirSync(manifestDir, { recursive: true });
209
+ function writeManifest(manifestPath, extensionId, wrapperPath) {
210
+ const origin = `chrome-extension://${extensionId}/`;
211
+ const existing = readExistingManifest(manifestPath);
212
+ const allowedOrigins = Array.isArray(existing.allowed_origins) ? existing.allowed_origins : [];
156
213
 
157
214
  const manifest = {
215
+ ...existing,
158
216
  name: HOST_NAME,
159
- description: "Surf CLI Native Host",
217
+ description: existing.description || "Surf CLI Native Host",
160
218
  path: wrapperPath,
161
219
  type: "stdio",
162
- allowed_origins: [`chrome-extension://${extensionId}/`],
220
+ allowed_origins: Array.from(new Set([...allowedOrigins, origin])),
163
221
  };
164
222
 
165
- const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
223
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
166
224
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
167
225
  return manifestPath;
168
226
  }
169
227
 
228
+ function getWslWindowsManifestDir(browserConfig) {
229
+ const localAppData = getWindowsEnv("LOCALAPPDATA");
230
+ if (!localAppData || !browserConfig.wsl) return null;
231
+ return path.join(windowsPathToWslPath(localAppData), browserConfig.wsl);
232
+ }
233
+
234
+ function installManifest(browser, extensionId, wrapperPath, target) {
235
+ const browserConfig = BROWSERS[browser];
236
+
237
+ if (!browserConfig) return null;
238
+
239
+ if (target === "wsl-windows") {
240
+ const manifestDir = getWslWindowsManifestDir(browserConfig);
241
+ if (!manifestDir) return null;
242
+ const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
243
+ return writeManifest(manifestPath, extensionId, wrapperPath);
244
+ }
245
+
246
+ const platform = process.platform;
247
+ if (!browserConfig[platform]) return null;
248
+
249
+ if (platform === "win32") {
250
+ return installWindowsRegistry(browser, extensionId, wrapperPath);
251
+ }
252
+
253
+ const manifestDir = path.join(os.homedir(), browserConfig[platform]);
254
+ const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
255
+ return writeManifest(manifestPath, extensionId, wrapperPath);
256
+ }
257
+
170
258
  function installWindowsRegistry(browser, extensionId, wrapperPath) {
171
259
  const browserConfig = BROWSERS[browser];
172
260
  const regPath = `HKCU\\Software\\${browserConfig.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
173
261
 
174
262
  const manifestDir = getWrapperDir();
175
263
  const manifestPath = path.join(manifestDir, `${HOST_NAME}.json`);
176
-
177
- const manifest = {
178
- name: HOST_NAME,
179
- description: "Surf CLI Native Host",
180
- path: wrapperPath,
181
- type: "stdio",
182
- allowed_origins: [`chrome-extension://${extensionId}/`],
183
- };
184
-
185
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
264
+ writeManifest(manifestPath, extensionId, wrapperPath);
186
265
 
187
266
  try {
188
267
  execSync(`reg add "${regPath}" /ve /t REG_SZ /d "${manifestPath}" /f`, {
@@ -197,7 +276,7 @@ function installWindowsRegistry(browser, extensionId, wrapperPath) {
197
276
 
198
277
  function parseArgs() {
199
278
  const args = process.argv.slice(2);
200
- const result = { extensionId: null, browsers: ["chrome"] };
279
+ const result = { extensionId: null, browsers: ["chrome"], target: "auto", listen: undefined };
201
280
 
202
281
  for (let i = 0; i < args.length; i++) {
203
282
  const arg = args[i];
@@ -208,6 +287,11 @@ function parseArgs() {
208
287
  } else {
209
288
  result.browsers = browserArg.split(",").map((b) => b.trim().toLowerCase());
210
289
  }
290
+ } else if (arg === "--target") {
291
+ result.target = args[++i];
292
+ } else if (arg === "--listen") {
293
+ result.listen = args[++i];
294
+ if (!result.listen || result.listen.startsWith("--")) throw new Error("--listen requires a Tailnet IP and port");
211
295
  } else if (arg === "--help" || arg === "-h") {
212
296
  printHelp();
213
297
  process.exit(0);
@@ -232,20 +316,30 @@ Options:
232
316
  -b, --browser Browser(s) to install for (default: chrome)
233
317
  Values: chrome, chromium, brave, edge, arc, helium, all
234
318
  Multiple: --browser chrome,brave
319
+ --target Install target: auto, linux, windows
320
+ On WSL2, auto installs for Windows Chrome. Use linux for WSLg/Linux browsers.
321
+ --listen <tailscale-ip>:<port>
322
+ Persist an authenticated Tailnet-only listener endpoint.
323
+ Requires at least one surf remote authorize client first.
324
+ Supports Tailscale IPv4 or IPv6 addresses; POSIX wrappers only.
235
325
 
236
326
  Examples:
237
327
  node install-native-host.cjs abcdefghijklmnopabcdefghijklmnop
238
328
  node install-native-host.cjs abcdefghijklmnop --browser brave
239
329
  node install-native-host.cjs abcdefghijklmnop --browser all
330
+ node install-native-host.cjs abcdefghijklmnop --target linux
331
+ node install-native-host.cjs abcdefghijklmnop --listen 100.64.1.2:4321
240
332
  `);
241
333
  }
242
334
 
243
335
  function main() {
244
- const { extensionId, browsers } = parseArgs();
336
+ let parsed;
337
+ try { parsed = parseArgs(); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
338
+ const { extensionId, browsers, target, listen } = parsed;
245
339
 
246
340
  if (!extensionId) {
247
341
  console.error("Error: Extension ID required");
248
- console.error("Usage: install-native-host.cjs <extension-id> [--browser chrome|chromium|brave|edge|arc|helium|all]");
342
+ console.error("Usage: install-native-host.cjs <extension-id> [--browser chrome|chromium|brave|edge|arc|helium|all] [--target auto|linux|windows]");
249
343
  console.error("\nFind your extension ID at chrome://extensions (enable Developer Mode)");
250
344
  process.exit(1);
251
345
  }
@@ -255,6 +349,36 @@ function main() {
255
349
  console.error("Expected 32 lowercase letters (a-p)");
256
350
  process.exit(1);
257
351
  }
352
+ let listener;
353
+ try {
354
+ listener = listen ? parseListenEndpoint(listen).display : undefined;
355
+ if (listener) {
356
+ const stateDir = getStateDir();
357
+ loadHostIdentity(stateDir);
358
+ if (loadRegistry(stateDir).clients.length === 0) {
359
+ throw new Error("--listen requires at least one authorized remote client; run `surf remote authorize <label> --output <path>` first");
360
+ }
361
+ }
362
+ } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
363
+
364
+ if (!["auto", "linux", "windows"].includes(target)) {
365
+ console.error("Error: Invalid --target value. Expected auto, linux, or windows");
366
+ process.exit(1);
367
+ }
368
+
369
+ const runningInWsl = isWsl();
370
+ if (target === "windows" && !runningInWsl && process.platform !== "win32") {
371
+ console.error("Error: --target windows is only supported on Windows or WSL2");
372
+ process.exit(1);
373
+ }
374
+
375
+ if (target === "linux" && process.platform !== "linux") {
376
+ console.error("Error: --target linux is only supported on Linux or WSL2");
377
+ process.exit(1);
378
+ }
379
+
380
+ const effectiveTarget = runningInWsl && target !== "linux" ? "wsl-windows" : process.platform;
381
+ try { assertListenTargetSupported(listen, effectiveTarget); } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); }
258
382
 
259
383
  const nodePath = findNode();
260
384
  if (!nodePath) {
@@ -270,19 +394,20 @@ function main() {
270
394
  process.exit(1);
271
395
  }
272
396
 
273
- const wrapperDir = getWrapperDir();
397
+ const wrapperDir = getWrapperDir(effectiveTarget);
274
398
  if (!wrapperDir) {
275
- console.error("Error: Unsupported platform");
399
+ console.error("Error: Unsupported platform or Windows interop unavailable");
276
400
  process.exit(1);
277
401
  }
278
402
 
279
- console.log(`Platform: ${process.platform}`);
403
+ console.log(`Platform: ${process.platform}${runningInWsl ? " (WSL2 detected)" : ""}`);
404
+ console.log(`Target: ${effectiveTarget === "wsl-windows" ? "Windows browser from WSL2" : effectiveTarget}`);
280
405
  console.log(`Node: ${nodePath}`);
281
406
  console.log(`Host: ${hostPath}`);
282
407
  console.log(`Wrapper dir: ${wrapperDir}`);
283
408
  console.log("");
284
409
 
285
- const wrapperPath = createWrapper(wrapperDir, nodePath, hostPath);
410
+ const wrapperPath = createWrapper(wrapperDir, nodePath, hostPath, effectiveTarget, listener);
286
411
  console.log(`Created wrapper: ${wrapperPath}`);
287
412
  console.log("");
288
413
 
@@ -295,7 +420,7 @@ function main() {
295
420
  continue;
296
421
  }
297
422
 
298
- const result = installManifest(browser, extensionId, wrapperPath);
423
+ const result = installManifest(browser, extensionId, wrapperPath, effectiveTarget);
299
424
  if (result) {
300
425
  installed.push({ browser: BROWSERS[browser].name, path: result });
301
426
  } else {
@@ -311,10 +436,18 @@ function main() {
311
436
  }
312
437
 
313
438
  if (skipped.length > 0) {
314
- console.log(`\nSkipped (not supported on ${process.platform}): ${skipped.join(", ")}`);
439
+ console.log(`\nSkipped (not supported for ${effectiveTarget}): ${skipped.join(", ")}`);
315
440
  }
316
441
 
317
442
  console.log("\nDone! Restart your browser for changes to take effect.");
318
443
  }
319
444
 
320
- main();
445
+ if (require.main === module) {
446
+ main();
447
+ }
448
+
449
+ module.exports = {
450
+ createWrapper,
451
+ writeManifest,
452
+ assertListenTargetSupported,
453
+ };