surf-cli 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/native/cli.cjs CHANGED
@@ -16,7 +16,7 @@ const {
16
16
  validateWorkflowArgs,
17
17
  validateWorkflowFile,
18
18
  } = require("./workflow-definition.cjs");
19
- const { executeDoSteps, sendDoRequest } = require("./do-executor.cjs");
19
+ const { executeDoSteps, semanticRequestContext, sendDoRequest } = require("./do-executor.cjs");
20
20
  const { runExtraction, renderExtractionMarkdown } = require("./extract.cjs");
21
21
  const { applyOptionsPrelude, parseScriptOptions } = require("./script-options.cjs");
22
22
  const { openClientTransport } = require("./client-transport.cjs");
@@ -202,6 +202,22 @@ try {
202
202
  process.exit(1);
203
203
  }
204
204
 
205
+ if (args[0] === "semantic" || args[0]?.startsWith("semantic.")) {
206
+ const { formatSemanticOutput, handleSemanticCli } = require("./semantic-cli.cjs");
207
+ handleSemanticCli(args, { endpoint })
208
+ .then((result) => {
209
+ if (!result.handled) throw new Error("Semantic command was not handled");
210
+ if (result.value !== undefined) console.log(formatSemanticOutput(result));
211
+ process.exit(0);
212
+ })
213
+ .catch((error) => {
214
+ const code = error?.code ? ` [${error.code}]` : "";
215
+ console.error(`Error: ${error?.message || String(error)}${code}`);
216
+ process.exit(1);
217
+ });
218
+ return;
219
+ }
220
+
205
221
  if (args[0] === "oracle") {
206
222
  if (args[1] === "ask" || args[1] === "follow") {
207
223
  console.error("[surf] Oracle requires exclusive browser access while dispatching; other sessions will queue.");
@@ -1200,6 +1216,15 @@ const TOOLS = {
1200
1216
  args: [],
1201
1217
  examples: [{ cmd: "frame.list", desc: "Show frame tree" }]
1202
1218
  },
1219
+ "frame.diagnose": {
1220
+ desc: "Compare DOM iframes, extension reachability, and the Chrome DevTools frame tree",
1221
+ args: [],
1222
+ opts: {
1223
+ "tab-id": "Target tab ID",
1224
+ json: "Print full frame inventories as JSON"
1225
+ },
1226
+ examples: [{ cmd: "frame.diagnose", desc: "Explain iframe access and count mismatches" }]
1227
+ },
1203
1228
  "frame.switch": {
1204
1229
  desc: "Switch to iframe context",
1205
1230
  args: [],
@@ -1308,7 +1333,10 @@ const TOOLS = {
1308
1333
  "on-error": "stop (default) | continue",
1309
1334
  "no-auto-wait": "Disable automatic waits between steps",
1310
1335
  "step-delay": "Delay between steps in ms (default: 100)",
1311
- "dry-run": "Parse and validate without executing"
1336
+ "dry-run": "Parse and validate without executing",
1337
+ "allow-semantic": "Allow bounded TypeSafe semantic decisions",
1338
+ "allow-write": "Allow declared semantic fill/check/click steps",
1339
+ "inputs-stdin": "Read bounded private input slots as one JSON object from stdin",
1312
1340
  },
1313
1341
  examples: [
1314
1342
  { cmd: 'do \'go "https://example.com" | click e5 | screenshot\'', desc: "Inline workflow" },
@@ -1584,8 +1612,19 @@ Tips:
1584
1612
  - Use window.new --incognito for isolated cookies`
1585
1613
  },
1586
1614
  semantic: {
1587
- title: "Semantic Locators",
1588
- content: `Find elements by role, text, or label instead of refs or selectors.
1615
+ title: "Semantic browser decisions and locators",
1616
+ content: `Optional Jev commands (page-derived text is sent to TypeSafe only for these commands):
1617
+ semantic.find "the notification control"
1618
+ semantic.verify "Notification preferences were saved"
1619
+ semantic.filter "notification preferences"
1620
+ semantic.act "Open notification settings" --max-steps 5
1621
+ semantic.act "Fill email" --input email="$EMAIL" --allow-write
1622
+ semantic auth set|status|clear
1623
+
1624
+ Every click/fill requires --allow-write. This broadly authorizes even high-impact controls;
1625
+ repeat --allow-ref <ref> to narrow authorization to exact observed refs.
1626
+
1627
+ Local semantic locators find elements by role, text, or label instead of refs or selectors.
1589
1628
 
1590
1629
  By ARIA role:
1591
1630
  locate.role button --name "Submit" --action click
@@ -1691,7 +1730,7 @@ const ALL_SOCKET_TOOLS = [
1691
1730
  "form.fill",
1692
1731
  "perf.start", "perf.stop", "perf.metrics",
1693
1732
  "upload",
1694
- "frame.list", "frame.switch", "frame.main", "frame.js",
1733
+ "frame.list", "frame.diagnose", "frame.switch", "frame.main", "frame.js",
1695
1734
  "cookie.list", "cookie.get", "cookie.set", "cookie.clear",
1696
1735
  "search", "batch",
1697
1736
  "zoom", "resize",
@@ -1714,6 +1753,7 @@ const SEE_ALSO = {
1714
1753
  "window.new": ["window.list"],
1715
1754
  "window.list": ["tab.list"],
1716
1755
  "frame.list": ["frame.switch", "frame.main"],
1756
+ "frame.diagnose": ["frame.list", "frame.switch", "frame.js"],
1717
1757
  "frame.switch": ["frame.list", "frame.main", "frame.js"],
1718
1758
  "frame.main": ["frame.list", "frame.switch"],
1719
1759
  "frame.js": ["frame.switch", "js"],
@@ -1767,6 +1807,8 @@ Common Commands:
1767
1807
  animate-audit JSON timeline of element animation/style samples
1768
1808
  perf-audit PerformanceObserver snapshot for motion/jank debugging
1769
1809
  page.read Get page accessibility tree (alias: read)
1810
+ semantic.find Optional Jev-powered candidate selection
1811
+ semantic.act Bounded semantic browser action controller
1770
1812
  locate.role <role> Find element by ARIA role
1771
1813
  search <term> Search for text in page (alias: find)
1772
1814
  window.new <url> Create isolated browser window
@@ -1844,6 +1886,11 @@ const showFullHelp = () => {
1844
1886
 
1845
1887
  Usage: surf <command> [args] [options]
1846
1888
 
1889
+ Semantic (optional TypeSafe/Jev):
1890
+ surf semantic.find|verify|filter <goal> [--json]
1891
+ surf semantic.act <goal> [--allow-write] [--allow-ref <ref>] [--input <name=value>]
1892
+ surf semantic auth <set|status|clear>
1893
+
1847
1894
  Oracle:
1848
1895
  surf oracle <ask|status|result|follow|list>
1849
1896
 
@@ -2407,9 +2454,12 @@ if (args[0] === "do") {
2407
2454
  let windowId = undefined;
2408
2455
  let explicitSession = undefined;
2409
2456
  let noWait = false;
2457
+ let allowSemantic = false;
2458
+ let allowWrite = false;
2459
+ let inputsStdin = false;
2410
2460
 
2411
2461
  // Reserved flags that aren't workflow args
2412
- const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'session', 'no-lock', 'no-wait'];
2462
+ const reservedFlags = ['file', 'f', 'dry-run', 'on-error', 'no-auto-wait', 'step-delay', 'json', 'tab-id', 'window-id', 'session', 'no-lock', 'no-wait', 'allow-semantic', 'allow-write', 'inputs-stdin'];
2413
2463
 
2414
2464
  // Workflow-specific args (collected for variable substitution)
2415
2465
  const workflowArgs = {};
@@ -2449,6 +2499,12 @@ if (args[0] === "do") {
2449
2499
  i++;
2450
2500
  } else if (arg === "--no-wait") {
2451
2501
  noWait = true;
2502
+ } else if (arg === "--allow-semantic") {
2503
+ allowSemantic = true;
2504
+ } else if (arg === "--allow-write") {
2505
+ allowWrite = true;
2506
+ } else if (arg === "--inputs-stdin") {
2507
+ inputsStdin = true;
2452
2508
  } else if (arg.startsWith("--")) {
2453
2509
  // Workflow-specific arg (e.g., --email, --password)
2454
2510
  const key = arg.slice(2);
@@ -2573,6 +2629,27 @@ if (args[0] === "do") {
2573
2629
 
2574
2630
  // Apply arg defaults
2575
2631
  const vars = workflow ? applyArgDefaults(workflow, workflowArgs) : workflowArgs;
2632
+ let privateInputs = {};
2633
+ if (inputsStdin) {
2634
+ try {
2635
+ const { SEMANTIC_POLICY } = require("./semantic-core.cjs");
2636
+ const input = fs.readFileSync(0, "utf8");
2637
+ if (Buffer.byteLength(input, "utf8") > 262144) throw new Error("input JSON exceeds 256 KiB");
2638
+ privateInputs = JSON.parse(input);
2639
+ if (!privateInputs || typeof privateInputs !== "object" || Array.isArray(privateInputs)) throw new Error("input JSON must be an object");
2640
+ const entries = Object.entries(privateInputs);
2641
+ if (entries.length > SEMANTIC_POLICY.limits.inputSlots) throw new Error(`input JSON supports at most ${SEMANTIC_POLICY.limits.inputSlots} slots`);
2642
+ for (const [name, value] of entries) {
2643
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error("input JSON contains an invalid slot name");
2644
+ if (!["string", "number", "boolean"].includes(typeof value)) throw new Error(`input slot '${name}' must be a string, number, or boolean`);
2645
+ if (Buffer.byteLength(String(value), "utf8") > SEMANTIC_POLICY.limits.inputValueBytes) throw new Error(`input slot '${name}' exceeds ${SEMANTIC_POLICY.limits.inputValueBytes / 1024} KiB`);
2646
+ if (Object.hasOwn(vars, name)) throw new Error(`input slot '${name}' was supplied more than once`);
2647
+ }
2648
+ } catch (error) {
2649
+ console.error(`Error: Invalid --inputs-stdin JSON: ${error.message}`);
2650
+ process.exit(1);
2651
+ }
2652
+ }
2576
2653
 
2577
2654
  // Validate with --dry-run
2578
2655
  if (dryRun) {
@@ -2593,6 +2670,21 @@ if (args[0] === "do") {
2593
2670
  process.exit(0);
2594
2671
  }
2595
2672
 
2673
+ const semanticEnabled = Boolean(workflow?.semantic);
2674
+ if (semanticEnabled && !allowSemantic) {
2675
+ console.error("Error: semantic workflows require --allow-semantic");
2676
+ process.exit(1);
2677
+ }
2678
+ if (semanticEnabled && onError === "continue") {
2679
+ console.error("Error: semantic workflows do not support --on-error continue");
2680
+ process.exit(1);
2681
+ }
2682
+ const writeOps = new Set(["ensureChecked", "fill", "click"]);
2683
+ if (semanticEnabled && steps.some((step) => writeOps.has(step.args?.op)) && !allowWrite) {
2684
+ console.error("Error: mutation-capable semantic steps require --allow-write");
2685
+ process.exit(1);
2686
+ }
2687
+
2596
2688
  if (!wantJson) {
2597
2689
  if (workflowName) {
2598
2690
  console.log(`Running workflow: ${workflowName} (${steps.length} steps)...\n`);
@@ -2620,6 +2712,17 @@ if (args[0] === "do") {
2620
2712
  endpoint,
2621
2713
  transport,
2622
2714
  },
2715
+ ...(semanticEnabled ? {
2716
+ createSemanticExecutor: async ({ context }) => {
2717
+ const { createConcreteSemanticExecutor } = require("./semantic-workflow-executor.cjs");
2718
+ return createConcreteSemanticExecutor({
2719
+ workflow,
2720
+ inputs: { ...vars, ...privateInputs },
2721
+ request: (tool, toolArgs, timeoutMs, identity) =>
2722
+ sendDoRequest(tool, toolArgs, semanticRequestContext(context, timeoutMs, identity)),
2723
+ });
2724
+ },
2725
+ } : {}),
2623
2726
  });
2624
2727
 
2625
2728
  // Print summary
@@ -35,6 +35,14 @@ function sendDoRequest(toolName, toolArgs, context = {}) {
35
35
  })();
36
36
  }
37
37
 
38
+ function semanticRequestContext(context, timeoutMs, identity) {
39
+ return {
40
+ ...context,
41
+ timeoutMs,
42
+ ...(identity ? { tabId: identity.tabId, windowId: undefined, session: undefined } : {}),
43
+ };
44
+ }
45
+
38
46
  function summarizeArgs(step) {
39
47
  return Object.entries(step.args || {})
40
48
  .map(([key, value]) => typeof value === "string" && value.length > 40 ? `${key}="${value.slice(0, 37)}..."` : `${key}=${JSON.stringify(value)}`)
@@ -63,14 +71,32 @@ function printProgress(event) {
63
71
 
64
72
  async function executeDoSteps(steps, options = {}) {
65
73
  const context = options.context || {};
66
- return runtime.executeWorkflow(steps, {
67
- ...options,
68
- executeTool: options.executeTool || ((tool, args) => sendDoRequest(tool, args, context)),
69
- onProgress: options.quiet ? options.onProgress : (event) => {
70
- printProgress(event);
71
- options.onProgress?.(event);
72
- },
73
- });
74
+ let semanticExecutor;
75
+ const executeSemanticStep = steps.some((step) => step.cmd === "semantic.step")
76
+ ? async (step, semanticContext, executionOptions) => {
77
+ if (!semanticExecutor) {
78
+ semanticExecutor = options.executeSemanticStep;
79
+ if (!semanticExecutor && typeof options.createSemanticExecutor === "function") {
80
+ semanticExecutor = await options.createSemanticExecutor({ context });
81
+ }
82
+ if (typeof semanticExecutor !== "function") throw new Error("semantic.step requires an injected semantic executor");
83
+ }
84
+ return semanticExecutor(step, semanticContext, executionOptions);
85
+ }
86
+ : undefined;
87
+ try {
88
+ return await runtime.executeWorkflow(steps, {
89
+ ...options,
90
+ executeTool: options.executeTool || ((tool, args) => sendDoRequest(tool, args, context)),
91
+ ...(executeSemanticStep ? { executeSemanticStep } : {}),
92
+ onProgress: options.quiet ? options.onProgress : (event) => {
93
+ printProgress(event);
94
+ options.onProgress?.(event);
95
+ },
96
+ });
97
+ } finally {
98
+ await semanticExecutor?.close?.();
99
+ }
74
100
  }
75
101
 
76
102
  module.exports = {
@@ -83,6 +109,7 @@ module.exports = {
83
109
  extractStepOutput: runtime.extractStepOutput,
84
110
  getAutoWaitCommand: runtime.getAutoWaitCommand,
85
111
  resolveVar: runtime.resolveVar,
112
+ semanticRequestContext,
86
113
  sendDoRequest,
87
114
  shouldAutoWait: runtime.shouldAutoWait,
88
115
  substituteVars: runtime.substituteVars,
package/native/doctor.cjs CHANGED
@@ -4,6 +4,17 @@ const os = require("os");
4
4
  const path = require("path");
5
5
  const { execFileSync } = require("child_process");
6
6
  const { connectEndpoint, selectEndpoint } = require("./endpoint.cjs");
7
+ const {
8
+ convertWindowsPath,
9
+ getWindowsEnv,
10
+ nativeMessagingRegistryPath,
11
+ runWindowsExecutable,
12
+ } = require("../scripts/windows-interop.cjs");
13
+ const {
14
+ renderWslWrapper,
15
+ probeWindowsWrapper,
16
+ } = require("./native-host-launch-probe.cjs");
17
+ const { findNode, getHostPath } = require("../scripts/install-native-host.cjs");
7
18
 
8
19
  const HOST_NAME = "surf.browser.host";
9
20
 
@@ -115,32 +126,12 @@ function resolveBrowsers(browserArg) {
115
126
  return browsers;
116
127
  }
117
128
 
118
- function getWindowsEnv(name, { env = process.env, execFileSync: execFile = execFileSync } = {}) {
119
- if (env[name]) return env[name];
120
- try {
121
- return execFile("cmd.exe", ["/c", "echo", `%${name}%`], { encoding: "utf8" })
122
- .trim()
123
- .replace(/\r/g, "");
124
- } catch {
125
- return null;
126
- }
127
- }
128
-
129
- function windowsPathToWslPath(winPath) {
130
- const normalized = winPath.replace(/\\/g, "/");
131
- const match = normalized.match(/^([A-Za-z]):\/(.*)$/);
132
- if (!match) return normalized;
133
- return `/mnt/${match[1].toLowerCase()}/${match[2]}`;
134
- }
135
-
136
129
  function manifestPathForBrowser(browserKey, context) {
137
130
  const browser = BROWSERS[browserKey];
138
131
  if (!browser) return null;
139
132
 
140
133
  if (context.effectiveTarget === "wsl-windows") {
141
- const localAppData = getWindowsEnv("LOCALAPPDATA", context);
142
- if (!localAppData || !browser.wsl) return null;
143
- return path.join(windowsPathToWslPath(localAppData), browser.wsl, `${HOST_NAME}.json`);
134
+ return null;
144
135
  }
145
136
 
146
137
  if (context.platform === "win32") {
@@ -156,7 +147,7 @@ function manifestPathForBrowser(browserKey, context) {
156
147
 
157
148
  function fsPathFromManifestPath(manifestPath, context) {
158
149
  if (context.platform === "linux" && /^[A-Za-z]:[\\/]/.test(manifestPath)) {
159
- return windowsPathToWslPath(manifestPath);
150
+ return convertWindowsPath(manifestPath, context);
160
151
  }
161
152
  return manifestPath;
162
153
  }
@@ -164,17 +155,21 @@ function fsPathFromManifestPath(manifestPath, context) {
164
155
  function windowsRegistryPathForBrowser(browserKey) {
165
156
  const browser = BROWSERS[browserKey];
166
157
  if (!browser?.win32) return null;
167
- return `HKCU\\Software\\${browser.win32}\\NativeMessagingHosts\\${HOST_NAME}`;
158
+ return nativeMessagingRegistryPath(browser.win32, HOST_NAME);
168
159
  }
169
160
 
170
161
  function readWindowsRegistryManifestPath(registryPath, context) {
171
162
  try {
172
- const output = context.execFileSync("reg", ["query", registryPath, "/ve"], { encoding: "utf8" });
163
+ const output = runWindowsExecutable("reg.exe", ["query", registryPath, "/ve"], {
164
+ execFileSync: context.execFileSync,
165
+ allowWslFallback: context.effectiveTarget === "wsl-windows",
166
+ execOptions: { encoding: "utf8" },
167
+ });
173
168
  const line = output.split(/\r?\n/).find((item) => item.includes("REG_SZ"));
174
- if (!line) return null;
175
- return line.replace(/^.*REG_SZ\s+/, "").trim() || null;
176
- } catch {
177
- return null;
169
+ if (!line) return { manifestPath: null, error: "registry output contained no REG_SZ default value" };
170
+ return { manifestPath: line.replace(/^.*REG_SZ\s+/, "").trim() || null, error: null };
171
+ } catch (error) {
172
+ return { manifestPath: null, error: error.message };
178
173
  }
179
174
  }
180
175
 
@@ -192,15 +187,16 @@ function checkWindowsRegistry(browserKey, context) {
192
187
  };
193
188
  }
194
189
 
195
- const manifestPath = readWindowsRegistryManifestPath(registryPath, context);
190
+ const registry = readWindowsRegistryManifestPath(registryPath, context);
191
+ const manifestPath = registry.manifestPath;
196
192
  return {
197
193
  check: {
198
194
  id: "windows-registry",
199
195
  status: manifestPath ? "pass" : "fail",
200
196
  browser: browserKey,
201
- message: manifestPath
202
- ? `Windows native messaging registry points to ${manifestPath}`
203
- : `Windows native messaging registry entry not found: ${registryPath}`,
197
+ message: !manifestPath
198
+ ? `Windows native messaging registry entry not found: ${registryPath}${registry.error ? ` (${registry.error})` : ""}`
199
+ : `Windows native messaging registry points to ${manifestPath}`,
204
200
  registryPath,
205
201
  path: manifestPath,
206
202
  },
@@ -210,19 +206,32 @@ function checkWindowsRegistry(browserKey, context) {
210
206
 
211
207
  function checkManifest(manifestPath, context) {
212
208
  const checks = [];
213
- const exists = manifestPath ? context.fs.existsSync(manifestPath) : false;
209
+ let manifestFsPath = manifestPath;
210
+ try {
211
+ manifestFsPath = manifestPath ? fsPathFromManifestPath(manifestPath, context) : manifestPath;
212
+ } catch (error) {
213
+ checks.push({
214
+ id: "manifest-file",
215
+ status: "fail",
216
+ message: `Could not resolve manifest path ${manifestPath}: ${error.message}`,
217
+ path: manifestPath,
218
+ });
219
+ return { checks, manifest: null };
220
+ }
221
+ const exists = manifestFsPath ? context.fs.existsSync(manifestFsPath) : false;
214
222
  checks.push({
215
223
  id: "manifest-file",
216
224
  status: exists ? "pass" : "fail",
217
- message: exists ? `Manifest found: ${manifestPath}` : "Native messaging manifest not found",
225
+ message: exists ? `Manifest found: ${manifestPath}` : `Native messaging manifest not found: ${manifestPath}`,
218
226
  path: manifestPath,
227
+ fsPath: manifestFsPath,
219
228
  });
220
229
 
221
230
  if (!exists) return { checks, manifest: null };
222
231
 
223
232
  let manifest;
224
233
  try {
225
- manifest = JSON.parse(context.fs.readFileSync(manifestPath, "utf8"));
234
+ manifest = JSON.parse(context.fs.readFileSync(manifestFsPath, "utf8"));
226
235
  checks.push({ id: "manifest-json", status: "pass", message: "Manifest JSON is valid" });
227
236
  } catch (error) {
228
237
  checks.push({ id: "manifest-json", status: "fail", message: `Manifest JSON is invalid: ${error.message}` });
@@ -288,6 +297,126 @@ function checkManifest(manifestPath, context) {
288
297
  return { checks, manifest };
289
298
  }
290
299
 
300
+ function normalizeWindowsPath(filePath) {
301
+ return path.win32.normalize(filePath).replace(/[\\/]+$/, "").toLowerCase();
302
+ }
303
+
304
+ function checkWslWrapperLaunch(manifest, context) {
305
+ if (!manifest || typeof manifest.path !== "string" || manifest.path.length === 0) {
306
+ return null;
307
+ }
308
+
309
+ let canonicalWindowsPath;
310
+ try {
311
+ const localAppData = getWindowsEnv("LOCALAPPDATA", { execFileSync: context.execFileSync });
312
+ canonicalWindowsPath = path.win32.join(
313
+ localAppData,
314
+ "surf-cli",
315
+ "host-wrapper-wsl.cmd",
316
+ );
317
+ } catch (error) {
318
+ return {
319
+ id: "wrapper-launch",
320
+ status: "fail",
321
+ message: `Could not resolve Surf's managed Windows wrapper path: ${error.message}`,
322
+ };
323
+ }
324
+
325
+ if (normalizeWindowsPath(manifest.path) !== normalizeWindowsPath(canonicalWindowsPath)) {
326
+ return {
327
+ id: "wrapper-launch",
328
+ status: "warn",
329
+ message:
330
+ "Skipped launch probe because the manifest does not point to Surf's managed WSL wrapper. Run `surf install <extension-id>` to restore the managed wrapper.",
331
+ path: manifest.path,
332
+ };
333
+ }
334
+
335
+ let wrapperFsPath;
336
+ try {
337
+ wrapperFsPath = fsPathFromManifestPath(canonicalWindowsPath, context);
338
+ } catch (error) {
339
+ return {
340
+ id: "wrapper-launch",
341
+ status: "fail",
342
+ message: `Could not resolve Surf's managed WSL wrapper: ${error.message}`,
343
+ path: canonicalWindowsPath,
344
+ };
345
+ }
346
+
347
+ if (!context.fs.existsSync(wrapperFsPath)) {
348
+ return {
349
+ id: "wrapper-launch",
350
+ status: "fail",
351
+ message: `Surf's managed WSL wrapper does not exist: ${canonicalWindowsPath}`,
352
+ path: canonicalWindowsPath,
353
+ fsPath: wrapperFsPath,
354
+ };
355
+ }
356
+
357
+ let wrapperContent;
358
+ try {
359
+ wrapperContent = context.fs.readFileSync(wrapperFsPath, "utf8");
360
+ } catch (error) {
361
+ return {
362
+ id: "wrapper-launch",
363
+ status: "fail",
364
+ message: `Could not read Surf's managed WSL wrapper: ${error.message}`,
365
+ path: canonicalWindowsPath,
366
+ fsPath: wrapperFsPath,
367
+ };
368
+ }
369
+
370
+ const nodePath = context.nodePath || findNode();
371
+ const hostPath = context.hostPath || getHostPath();
372
+ const distro = context.env.WSL_DISTRO_NAME;
373
+ let explicitWrapper = null;
374
+ let defaultWrapper = null;
375
+ if (nodePath && hostPath && distro) {
376
+ try {
377
+ explicitWrapper = renderWslWrapper(nodePath, hostPath, distro);
378
+ defaultWrapper = renderWslWrapper(nodePath, hostPath, null);
379
+ } catch {
380
+ // Do not execute a wrapper whose installed paths cannot be rendered safely.
381
+ }
382
+ }
383
+ if (wrapperContent !== explicitWrapper && wrapperContent !== defaultWrapper) {
384
+ return {
385
+ id: "wrapper-launch",
386
+ status: "warn",
387
+ message:
388
+ "Surf's managed WSL wrapper does not match this installation, so it was not executed. Run `surf install <extension-id>` to replace it.",
389
+ path: canonicalWindowsPath,
390
+ fsPath: wrapperFsPath,
391
+ };
392
+ }
393
+
394
+ try {
395
+ const observedDistro = context.probeWindowsWrapper(canonicalWindowsPath, {
396
+ execFileSync: context.execFileSync,
397
+ verifyDistro: wrapperContent === defaultWrapper,
398
+ });
399
+ if (wrapperContent === defaultWrapper && observedDistro !== distro) {
400
+ throw new Error("Windows default WSL distro does not match this installation");
401
+ }
402
+ return {
403
+ id: "wrapper-launch",
404
+ status: "pass",
405
+ message: "Surf's managed WSL wrapper completed its launch probe",
406
+ path: canonicalWindowsPath,
407
+ fsPath: wrapperFsPath,
408
+ };
409
+ } catch (error) {
410
+ return {
411
+ id: "wrapper-launch",
412
+ status: "fail",
413
+ message: `Surf's managed WSL wrapper failed validation: ${error.message}`,
414
+ path: canonicalWindowsPath,
415
+ fsPath: wrapperFsPath,
416
+ };
417
+ }
418
+ }
419
+
291
420
  async function checkSocket(socketPath, context) {
292
421
  const checks = [];
293
422
  if (context.platform !== "win32") {
@@ -371,6 +500,20 @@ function summarize(checks) {
371
500
  function buildRecommendations(report) {
372
501
  const recommendations = [];
373
502
  const failedIds = new Set(report.checks.filter((check) => check.status === "fail").map((check) => check.id));
503
+ const debugRelevantIds = new Set([
504
+ "windows-registry",
505
+ "manifest-file",
506
+ "manifest-json",
507
+ "manifest-shape",
508
+ "manifest-name",
509
+ "manifest-type",
510
+ "manifest-origins",
511
+ "manifest-path",
512
+ "manifest-path-executable",
513
+ "wrapper-launch",
514
+ "socket-file",
515
+ "socket-connect",
516
+ ]);
374
517
 
375
518
  if (failedIds.has("windows-registry")) {
376
519
  recommendations.push("Run `surf install <extension-id> --browser <browser>` so Windows registers the native messaging host, then restart the browser.");
@@ -384,12 +527,18 @@ function buildRecommendations(report) {
384
527
  if (failedIds.has("manifest-path") || failedIds.has("manifest-path-executable")) {
385
528
  recommendations.push("Reinstall the native host so the manifest path points at the current Surf wrapper.");
386
529
  }
530
+ if (failedIds.has("wrapper-launch")) {
531
+ recommendations.push("Rerun `surf install <extension-id>` from the same WSL distro so Surf can replace and validate the Windows wrapper.");
532
+ }
387
533
  if (failedIds.has("manifest-supported")) {
388
534
  recommendations.push("Choose a browser supported for this target, or rerun with `--browser all` to inspect every supported setup.");
389
535
  }
390
536
  if (failedIds.has("socket-file") || failedIds.has("socket-connect")) {
391
537
  recommendations.push("Make sure the browser is running with the Surf extension enabled, then restart the browser after install changes.");
392
538
  }
539
+ if ([...failedIds].some((id) => debugRelevantIds.has(id))) {
540
+ recommendations.push("Open chrome://extensions and inspect Surf's service worker console. In Surf's Details > Extension options, enable Debug Mode, reproduce the failure, then disable Debug Mode when finished.");
541
+ }
393
542
  if (report.environment.surfSocketSet) {
394
543
  recommendations.push("SURF_SOCKET is set; make sure Chrome launches the native host with the same socket value.");
395
544
  }
@@ -478,6 +627,9 @@ async function runDoctor(rawOptions = {}, deps = {}) {
478
627
  effectiveTarget,
479
628
  fs: deps.fs || fs,
480
629
  execFileSync: deps.execFileSync || execFileSync,
630
+ probeWindowsWrapper: deps.probeWindowsWrapper || probeWindowsWrapper,
631
+ nodePath: deps.nodePath,
632
+ hostPath: deps.hostPath,
481
633
  connectSocket: deps.connectSocket || connectSocket,
482
634
  connectTimeoutMs: options.connectTimeoutMs,
483
635
  };
@@ -493,15 +645,20 @@ async function runDoctor(rawOptions = {}, deps = {}) {
493
645
  for (const browserKey of browsers) {
494
646
  const browser = BROWSERS[browserKey];
495
647
  const browserChecks = [];
496
- let manifestPath = manifestPathForBrowser(browserKey, context);
648
+ const usesWindowsRegistry =
649
+ (context.platform === "win32" || context.effectiveTarget === "wsl-windows") &&
650
+ Boolean(browser.win32);
651
+ let manifestPath = null;
497
652
 
498
- if (context.platform === "win32" && browser.win32) {
653
+ if (usesWindowsRegistry) {
499
654
  const registry = checkWindowsRegistry(browserKey, context);
500
655
  browserChecks.push(registry.check);
501
- if (registry.manifestPath) manifestPath = registry.manifestPath;
656
+ manifestPath = registry.manifestPath;
657
+ } else {
658
+ manifestPath = manifestPathForBrowser(browserKey, context);
502
659
  }
503
660
 
504
- if (!manifestPath) {
661
+ if (!manifestPath && !usesWindowsRegistry) {
505
662
  const check = {
506
663
  id: "manifest-supported",
507
664
  status: options.browser === "all" ? "warn" : "fail",
@@ -516,6 +673,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
516
673
 
517
674
  const result = checkManifest(manifestPath, context);
518
675
  browserChecks.push(...result.checks.map((check) => ({ ...check, browser: browserKey })));
676
+ if (context.effectiveTarget === "wsl-windows" && result.manifest) {
677
+ const wrapperLaunch = checkWslWrapperLaunch(result.manifest, context);
678
+ if (wrapperLaunch) browserChecks.push({ ...wrapperLaunch, browser: browserKey });
679
+ }
519
680
  checks.push(...browserChecks);
520
681
  manifests.push({
521
682
  browser: browserKey,
@@ -638,5 +799,4 @@ module.exports = {
638
799
  parseDoctorArgs,
639
800
  runDoctor,
640
801
  runDoctorCli,
641
- windowsPathToWslPath,
642
802
  };