surf-cli 2.18.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/README.md +276 -8
- package/dist/content/index.js +4 -4
- package/dist/content/index.js.map +1 -1
- package/dist/options/options.html +1 -18
- package/dist/service-worker/index.js +44 -15
- package/dist/service-worker/index.js.map +1 -1
- package/native/cli.cjs +430 -14
- package/native/do-executor.cjs +35 -8
- package/native/doctor.cjs +211 -42
- package/native/endpoint.cjs +121 -36
- package/native/extract.cjs +362 -0
- package/native/host-helpers.cjs +133 -11
- package/native/host-sessions.cjs +10 -0
- package/native/host.cjs +53 -23
- package/native/mcp-server.cjs +25 -0
- package/native/native-host-launch-probe.cjs +69 -0
- package/native/private-state.cjs +25 -1
- package/native/script-options.cjs +33 -0
- package/native/semantic-cli.cjs +764 -0
- package/native/semantic-core.cjs +369 -0
- package/native/semantic-credentials.cjs +207 -0
- package/native/semantic-provider.cjs +61 -0
- package/native/semantic-workflow-executor.cjs +65 -0
- package/native/semantic-workflow-state.cjs +271 -0
- package/native/semantic-workflow.cjs +398 -0
- package/native/stdin-frames.cjs +33 -0
- package/native/tool-scope.cjs +3 -2
- package/native/workflow-definition.cjs +125 -1
- package/native/workflow-runtime.cjs +71 -5
- package/package.json +11 -7
- package/scripts/install-native-host.cjs +103 -60
- package/scripts/uninstall-native-host.cjs +40 -38
- package/scripts/windows-interop.cjs +89 -0
- package/skills/surf/SKILL.md +96 -1
package/native/do-executor.cjs
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
158
|
+
return nativeMessagingRegistryPath(browser.win32, HOST_NAME);
|
|
168
159
|
}
|
|
169
160
|
|
|
170
161
|
function readWindowsRegistryManifestPath(registryPath, context) {
|
|
171
162
|
try {
|
|
172
|
-
const output =
|
|
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
|
|
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
|
|
203
|
-
: `Windows native messaging registry
|
|
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
|
-
|
|
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}` :
|
|
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(
|
|
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
|
}
|
|
@@ -405,6 +554,14 @@ function buildRecommendations(report) {
|
|
|
405
554
|
|
|
406
555
|
function remoteRecommendations(endpoint, code) {
|
|
407
556
|
const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
|
|
557
|
+
const tlsCodes = new Set([
|
|
558
|
+
"ERR_TLS_CERT_ALTNAME_INVALID", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "SELF_SIGNED_CERT_IN_CHAIN",
|
|
559
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT", "UNABLE_TO_GET_ISSUER_CERT", "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
560
|
+
"CERT_HAS_EXPIRED",
|
|
561
|
+
]);
|
|
562
|
+
if (endpoint.tls?.enabled && (code === "ETIMEDOUT" || tlsCodes.has(code))) {
|
|
563
|
+
return [...base, "Check the reverse-proxy certificate chain and expected server name (or --remote-tls-server-name); a custom CA replaces system roots."];
|
|
564
|
+
}
|
|
408
565
|
if (code === "ENOTFOUND") return [...base, "Check the Tailnet DNS name, then run `tailscale status` and `tailscale ping <host>`."].map((item) => item.replace("<host>", endpoint.host));
|
|
409
566
|
if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
|
|
410
567
|
if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
|
|
@@ -445,9 +602,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
445
602
|
socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
|
|
446
603
|
socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
|
|
447
604
|
})))(endpoint, options.connectTimeoutMs);
|
|
448
|
-
const
|
|
605
|
+
const tlsMarker = endpoint.tls?.enabled ? " (TLS)" : "";
|
|
606
|
+
const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}${tlsMarker}`, endpoint: endpoint.display }, {
|
|
449
607
|
id: "remote-connect", status: connection.ok ? "pass" : "fail",
|
|
450
|
-
message: connection.ok ? `Connected to remote endpoint ${endpoint.display}` : `Could not connect to remote endpoint ${endpoint.display}: ${connection.message}`,
|
|
608
|
+
message: connection.ok ? `Connected to remote endpoint ${endpoint.display}${tlsMarker}` : `Could not connect to remote endpoint ${endpoint.display}${tlsMarker}: ${connection.message}`,
|
|
451
609
|
code: connection.code,
|
|
452
610
|
}, {
|
|
453
611
|
id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
|
|
@@ -469,6 +627,9 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
469
627
|
effectiveTarget,
|
|
470
628
|
fs: deps.fs || fs,
|
|
471
629
|
execFileSync: deps.execFileSync || execFileSync,
|
|
630
|
+
probeWindowsWrapper: deps.probeWindowsWrapper || probeWindowsWrapper,
|
|
631
|
+
nodePath: deps.nodePath,
|
|
632
|
+
hostPath: deps.hostPath,
|
|
472
633
|
connectSocket: deps.connectSocket || connectSocket,
|
|
473
634
|
connectTimeoutMs: options.connectTimeoutMs,
|
|
474
635
|
};
|
|
@@ -484,15 +645,20 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
484
645
|
for (const browserKey of browsers) {
|
|
485
646
|
const browser = BROWSERS[browserKey];
|
|
486
647
|
const browserChecks = [];
|
|
487
|
-
|
|
648
|
+
const usesWindowsRegistry =
|
|
649
|
+
(context.platform === "win32" || context.effectiveTarget === "wsl-windows") &&
|
|
650
|
+
Boolean(browser.win32);
|
|
651
|
+
let manifestPath = null;
|
|
488
652
|
|
|
489
|
-
if (
|
|
653
|
+
if (usesWindowsRegistry) {
|
|
490
654
|
const registry = checkWindowsRegistry(browserKey, context);
|
|
491
655
|
browserChecks.push(registry.check);
|
|
492
|
-
|
|
656
|
+
manifestPath = registry.manifestPath;
|
|
657
|
+
} else {
|
|
658
|
+
manifestPath = manifestPathForBrowser(browserKey, context);
|
|
493
659
|
}
|
|
494
660
|
|
|
495
|
-
if (!manifestPath) {
|
|
661
|
+
if (!manifestPath && !usesWindowsRegistry) {
|
|
496
662
|
const check = {
|
|
497
663
|
id: "manifest-supported",
|
|
498
664
|
status: options.browser === "all" ? "warn" : "fail",
|
|
@@ -507,6 +673,10 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
507
673
|
|
|
508
674
|
const result = checkManifest(manifestPath, context);
|
|
509
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
|
+
}
|
|
510
680
|
checks.push(...browserChecks);
|
|
511
681
|
manifests.push({
|
|
512
682
|
browser: browserKey,
|
|
@@ -629,5 +799,4 @@ module.exports = {
|
|
|
629
799
|
parseDoctorArgs,
|
|
630
800
|
runDoctor,
|
|
631
801
|
runDoctorCli,
|
|
632
|
-
windowsPathToWslPath,
|
|
633
802
|
};
|
package/native/endpoint.cjs
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
const net = require("net");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const tls = require("tls");
|
|
2
4
|
const { DEFAULT_SOCKET_PATH } = require("./socket-path.cjs");
|
|
3
5
|
const { authenticateClient } = require("./remote-transport.cjs");
|
|
4
6
|
|
|
7
|
+
const TLS_HANDSHAKE_TIMEOUT_MS = 5000;
|
|
8
|
+
const HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
9
|
+
|
|
5
10
|
function parseRemoteEndpoint(value) {
|
|
6
11
|
if (typeof value !== "string" || !value) throw new Error("--remote requires host:port");
|
|
7
12
|
let host;
|
|
@@ -16,7 +21,7 @@ function parseRemoteEndpoint(value) {
|
|
|
16
21
|
if (!match) throw new Error("remote endpoint must be host:port (IPv6 must be bracketed)");
|
|
17
22
|
[, host, portText] = match;
|
|
18
23
|
if (host.includes("/") || host.includes("@") || host.includes(":") || host === "*" || host.includes("*")) throw new Error("remote endpoint host is invalid");
|
|
19
|
-
if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 &&
|
|
24
|
+
if ((/^\d+(?:\.\d+){3}$/.test(host) && net.isIP(host) !== 4) || (net.isIP(host) !== 4 && !HOSTNAME_PATTERN.test(host))) {
|
|
20
25
|
throw new Error("remote endpoint host is invalid");
|
|
21
26
|
}
|
|
22
27
|
host = host.toLowerCase();
|
|
@@ -30,45 +35,107 @@ function parseRemoteEndpoint(value) {
|
|
|
30
35
|
return { kind: "remote", host, port, display, key: `tcp:${display}`, connectionOptions: { host, port } };
|
|
31
36
|
}
|
|
32
37
|
|
|
38
|
+
function extractRemoteOptions(args) {
|
|
39
|
+
const definitions = {
|
|
40
|
+
"--remote": { name: "remote", missing: "--remote requires host:port" },
|
|
41
|
+
"--remote-credential": { name: "credential", missing: "--remote-credential requires a file path" },
|
|
42
|
+
"--remote-tls": { name: "tls", boolean: true },
|
|
43
|
+
"--remote-tls-ca": { name: "tlsCa", missing: "--remote-tls-ca requires a file path" },
|
|
44
|
+
"--remote-tls-server-name": { name: "tlsServerName", missing: "--remote-tls-server-name requires a DNS hostname" },
|
|
45
|
+
};
|
|
46
|
+
const values = {};
|
|
47
|
+
const strippedArgs = [];
|
|
48
|
+
for (let index = 0; index < args.length; index++) {
|
|
49
|
+
const option = definitions[args[index]];
|
|
50
|
+
if (!option) {
|
|
51
|
+
strippedArgs.push(args[index]);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (Object.hasOwn(values, option.name)) throw new Error(`${args[index]} may only be specified once`);
|
|
55
|
+
if (option.boolean) {
|
|
56
|
+
values[option.name] = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const value = args[index + 1];
|
|
60
|
+
if (!value || value.startsWith("--")) throw new Error(option.missing);
|
|
61
|
+
values[option.name] = value;
|
|
62
|
+
index++;
|
|
63
|
+
}
|
|
64
|
+
return { values, strippedArgs };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateServerName(value, source) {
|
|
68
|
+
if (!value || net.isIP(value) !== 0 || !HOSTNAME_PATTERN.test(value)) {
|
|
69
|
+
throw new Error(`${source} must be a valid DNS hostname`);
|
|
70
|
+
}
|
|
71
|
+
return value.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadTlsCa(caPath, source) {
|
|
75
|
+
let ca;
|
|
76
|
+
try {
|
|
77
|
+
ca = fs.readFileSync(caPath);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`${source} could not read CA file ${caPath}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (ca.length === 0 || !ca.includes(Buffer.from("-----BEGIN CERTIFICATE-----"))) {
|
|
82
|
+
throw new Error(`${source} CA file ${caPath} must contain a PEM certificate`);
|
|
83
|
+
}
|
|
84
|
+
return ca;
|
|
85
|
+
}
|
|
86
|
+
|
|
33
87
|
function selectEndpoint(args, env) {
|
|
34
88
|
const selectedEnv = env === undefined ? process.env : env;
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (args[i] === "--remote-credential") credentialIndexes.push(i);
|
|
40
|
-
}
|
|
41
|
-
if (remoteIndexes.length > 1) throw new Error("--remote may only be specified once");
|
|
42
|
-
if (credentialIndexes.length > 1) throw new Error("--remote-credential may only be specified once");
|
|
43
|
-
let cliRemote;
|
|
44
|
-
let cliCredential;
|
|
45
|
-
const strippedArgs = [...args];
|
|
46
|
-
if (remoteIndexes.length) {
|
|
47
|
-
const index = remoteIndexes[0];
|
|
48
|
-
cliRemote = args[index + 1];
|
|
49
|
-
if (!cliRemote || cliRemote.startsWith("--")) throw new Error("--remote requires host:port");
|
|
50
|
-
strippedArgs.splice(index, 2);
|
|
89
|
+
const { values, strippedArgs } = extractRemoteOptions(args);
|
|
90
|
+
const envTls = selectedEnv.SURF_REMOTE_TLS;
|
|
91
|
+
if (envTls && envTls !== "1") {
|
|
92
|
+
throw new Error('SURF_REMOTE_TLS must be "1" to enable TLS; unset it to disable');
|
|
51
93
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
94
|
+
const remoteValue = values.remote || selectedEnv.SURF_REMOTE;
|
|
95
|
+
const credentialPath = values.credential || selectedEnv.SURF_REMOTE_CREDENTIAL;
|
|
96
|
+
const tlsEnabled = values.tls === true || envTls === "1";
|
|
97
|
+
const caPath = values.tlsCa || selectedEnv.SURF_REMOTE_TLS_CA;
|
|
98
|
+
const serverNameValue = values.tlsServerName || selectedEnv.SURF_REMOTE_TLS_SERVER_NAME;
|
|
99
|
+
if (!remoteValue) {
|
|
100
|
+
if (values.credential) throw new Error("--remote-credential requires a remote endpoint");
|
|
101
|
+
if (values.tls) throw new Error("--remote-tls requires a remote endpoint");
|
|
102
|
+
if (selectedEnv.SURF_REMOTE_TLS === "1") throw new Error("SURF_REMOTE_TLS requires a remote endpoint");
|
|
103
|
+
if (values.tlsCa) throw new Error("--remote-tls-ca requires a remote endpoint");
|
|
104
|
+
if (selectedEnv.SURF_REMOTE_TLS_CA) throw new Error("SURF_REMOTE_TLS_CA requires a remote endpoint");
|
|
105
|
+
if (values.tlsServerName) throw new Error("--remote-tls-server-name requires a remote endpoint");
|
|
106
|
+
if (selectedEnv.SURF_REMOTE_TLS_SERVER_NAME) throw new Error("SURF_REMOTE_TLS_SERVER_NAME requires a remote endpoint");
|
|
107
|
+
const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
|
|
108
|
+
return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
|
|
58
109
|
}
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
110
|
+
if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
|
|
111
|
+
if (caPath && !tlsEnabled) throw new Error(`${values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA"} requires TLS to be enabled`);
|
|
112
|
+
if (serverNameValue && !tlsEnabled) throw new Error(`${values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME"} requires TLS to be enabled`);
|
|
113
|
+
const endpoint = { ...parseRemoteEndpoint(remoteValue), credentialPath };
|
|
114
|
+
if (tlsEnabled) {
|
|
115
|
+
const tlsOptions = { enabled: true };
|
|
116
|
+
if (caPath) {
|
|
117
|
+
tlsOptions.ca = loadTlsCa(caPath, values.tlsCa ? "--remote-tls-ca" : "SURF_REMOTE_TLS_CA");
|
|
118
|
+
tlsOptions.caPath = caPath;
|
|
119
|
+
}
|
|
120
|
+
const serverName = serverNameValue
|
|
121
|
+
? validateServerName(serverNameValue, values.tlsServerName ? "--remote-tls-server-name" : "SURF_REMOTE_TLS_SERVER_NAME")
|
|
122
|
+
: net.isIP(endpoint.host) === 0 ? endpoint.host : undefined;
|
|
123
|
+
if (serverName) tlsOptions.serverName = serverName;
|
|
124
|
+
endpoint.tls = tlsOptions;
|
|
64
125
|
}
|
|
65
|
-
|
|
66
|
-
const socketPath = selectedEnv.SURF_SOCKET || DEFAULT_SOCKET_PATH;
|
|
67
|
-
return { args: strippedArgs, endpoint: { kind: "local", path: socketPath, display: socketPath, key: `unix:${socketPath}`, connectionOptions: socketPath } };
|
|
126
|
+
return { args: strippedArgs, endpoint };
|
|
68
127
|
}
|
|
69
128
|
|
|
70
129
|
function createRemoteSocket(endpoint) {
|
|
71
|
-
const
|
|
130
|
+
const usingTls = endpoint.tls?.enabled === true;
|
|
131
|
+
const rawSocket = usingTls
|
|
132
|
+
? tls.connect({
|
|
133
|
+
...endpoint.connectionOptions,
|
|
134
|
+
rejectUnauthorized: true,
|
|
135
|
+
...(endpoint.tls.ca ? { ca: endpoint.tls.ca } : {}),
|
|
136
|
+
...(endpoint.tls.serverName ? { servername: endpoint.tls.serverName } : {}),
|
|
137
|
+
})
|
|
138
|
+
: net.createConnection(endpoint.connectionOptions, () => {});
|
|
72
139
|
let ready = false;
|
|
73
140
|
let connected = false;
|
|
74
141
|
let destroyed = false;
|
|
@@ -106,7 +173,25 @@ function createRemoteSocket(endpoint) {
|
|
|
106
173
|
get authenticated() { return ready; },
|
|
107
174
|
get connected() { return connected; },
|
|
108
175
|
};
|
|
109
|
-
|
|
176
|
+
const transportReadyEvent = usingTls ? "secureConnect" : "connect";
|
|
177
|
+
rawSocket.once(transportReadyEvent, () => { connected = true; });
|
|
178
|
+
let handshakeTimer;
|
|
179
|
+
if (usingTls) {
|
|
180
|
+
const timeoutMs = endpoint.tls.handshakeTimeoutMs ?? TLS_HANDSHAKE_TIMEOUT_MS;
|
|
181
|
+
const clearHandshakeTimer = () => {
|
|
182
|
+
if (handshakeTimer) clearTimeout(handshakeTimer);
|
|
183
|
+
handshakeTimer = undefined;
|
|
184
|
+
};
|
|
185
|
+
handshakeTimer = setTimeout(() => {
|
|
186
|
+
if (connected || rawSocket.destroyed) return;
|
|
187
|
+
const error = new Error(`TLS handshake timed out after ${timeoutMs}ms`);
|
|
188
|
+
error.code = "ETIMEDOUT";
|
|
189
|
+
rawSocket.destroy(error);
|
|
190
|
+
}, timeoutMs);
|
|
191
|
+
rawSocket.once("secureConnect", clearHandshakeTimer);
|
|
192
|
+
rawSocket.once("error", clearHandshakeTimer);
|
|
193
|
+
rawSocket.once("close", clearHandshakeTimer);
|
|
194
|
+
}
|
|
110
195
|
rawSocket.on("error", (error) => {
|
|
111
196
|
if (ready || destroyed) return;
|
|
112
197
|
ready = true;
|
|
@@ -133,7 +218,7 @@ function connectEndpoint(endpoint, onConnect) {
|
|
|
133
218
|
return net.createConnection(endpoint.connectionOptions, onConnect || (() => {}));
|
|
134
219
|
}
|
|
135
220
|
const { rawSocket, proxy, flush } = createRemoteSocket(endpoint);
|
|
136
|
-
rawSocket.once("connect", () => {
|
|
221
|
+
rawSocket.once(endpoint.tls?.enabled ? "secureConnect" : "connect", () => {
|
|
137
222
|
authenticateClient(rawSocket, endpoint.credentialPath)
|
|
138
223
|
.then(() => {
|
|
139
224
|
flush();
|
|
@@ -168,7 +253,7 @@ function connectEndpoint(endpoint, onConnect) {
|
|
|
168
253
|
function formatEndpointError(error, endpoint, formatSocketError) {
|
|
169
254
|
if (endpoint.kind === "local") return formatSocketError(error);
|
|
170
255
|
const message = error?.message || String(error);
|
|
171
|
-
return `Remote endpoint connection failed (${endpoint.display}): ${message}`;
|
|
256
|
+
return `Remote endpoint connection failed (${endpoint.display}${endpoint.tls?.enabled ? ", TLS" : ""}): ${message}`;
|
|
172
257
|
}
|
|
173
258
|
|
|
174
|
-
module.exports = { parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
|
|
259
|
+
module.exports = { TLS_HANDSHAKE_TIMEOUT_MS, parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
|