surf-cli 2.8.0 → 2.10.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 +146 -8
- package/native/abort.cjs +65 -0
- package/native/activity-journal.cjs +55 -0
- package/native/ai-queue.cjs +64 -0
- package/native/aistudio-build.cjs +21 -13
- package/native/aistudio-client.cjs +40 -20
- package/native/browser-lock.cjs +2 -2
- package/native/chatgpt-client.cjs +49 -31
- package/native/cli.cjs +352 -482
- package/native/client-transport.cjs +168 -0
- package/native/do-executor.cjs +68 -510
- package/native/do-parser.cjs +8 -249
- package/native/doctor.cjs +55 -5
- package/native/endpoint.cjs +174 -0
- package/native/file-transfer.cjs +734 -0
- package/native/gemini-client.cjs +156 -71
- package/native/grok-client.cjs +98 -89
- package/native/host-helpers.cjs +43 -26
- package/native/host-sessions.cjs +287 -0
- package/native/host.cjs +998 -620
- package/native/listener.cjs +20 -0
- package/native/mcp-server.cjs +60 -65
- package/native/network-export.cjs +116 -0
- package/native/network-store.cjs +38 -58
- package/native/perplexity-client.cjs +46 -17
- package/native/playbook-authoring.cjs +44 -0
- package/native/playbook-cli.cjs +157 -0
- package/native/playbook-client.cjs +259 -0
- package/native/playbook-receipts.cjs +109 -0
- package/native/playbook-records.cjs +208 -0
- package/native/playbook-runtime.cjs +177 -0
- package/native/playbooks.cjs +235 -0
- package/native/private-state.cjs +156 -0
- package/native/redaction.cjs +104 -0
- package/native/remote-auth.cjs +279 -0
- package/native/remote-transport.cjs +337 -0
- package/native/request-pending.cjs +148 -0
- package/native/socket-path.cjs +1 -1
- package/native/workflow-definition.cjs +368 -0
- package/native/workflow-runtime.cjs +225 -0
- package/package.json +9 -6
- package/playbooks/page/ops/read.json +22 -0
- package/playbooks/page/playbook.json +7 -0
- package/scripts/install-native-host.cjs +36 -5
- package/skills/README.md +11 -5
- package/skills/deep-x-research/SKILL.md +106 -0
- package/skills/surf/SKILL.md +72 -5
package/native/do-parser.cjs
CHANGED
|
@@ -1,250 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* screenshot'
|
|
10
|
-
*
|
|
11
|
-
* Output:
|
|
12
|
-
* [
|
|
13
|
-
* { cmd: 'navigate', args: { url: 'https://example.com' } },
|
|
14
|
-
* { cmd: 'click', args: { ref: 'e5' } },
|
|
15
|
-
* { cmd: 'screenshot', args: {} }
|
|
16
|
-
* ]
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
// Aliases mapping (matches cli.cjs)
|
|
20
|
-
const ALIASES = {
|
|
21
|
-
snap: "screenshot",
|
|
22
|
-
read: "page.read",
|
|
23
|
-
find: "search",
|
|
24
|
-
go: "navigate",
|
|
25
|
-
net: "network",
|
|
26
|
-
"network.dump": "network.get",
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// Primary argument mapping for positional args (matches cli.cjs)
|
|
30
|
-
const PRIMARY_ARG_MAP = {
|
|
31
|
-
ai: "query",
|
|
32
|
-
gemini: "query",
|
|
33
|
-
chatgpt: "query",
|
|
34
|
-
perplexity: "query",
|
|
35
|
-
grok: "query",
|
|
36
|
-
navigate: "url",
|
|
37
|
-
go: "url",
|
|
38
|
-
js: "code",
|
|
39
|
-
javascript_tool: "code",
|
|
40
|
-
key: "key",
|
|
41
|
-
wait: "duration",
|
|
42
|
-
health: "url",
|
|
43
|
-
new_tab: "url",
|
|
44
|
-
"tab.new": "url",
|
|
45
|
-
switch_tab: "tab_id",
|
|
46
|
-
"tab.switch": "id",
|
|
47
|
-
close_tab: "tab_id",
|
|
48
|
-
"tab.close": "id",
|
|
49
|
-
"tab.name": "name",
|
|
50
|
-
"tab.unname": "name",
|
|
51
|
-
scroll_to_position: "position",
|
|
52
|
-
type: "text",
|
|
53
|
-
smart_type: "text",
|
|
54
|
-
"emulate.network": "preset",
|
|
55
|
-
"emulate.cpu": "rate",
|
|
56
|
-
search: "term",
|
|
57
|
-
find: "term",
|
|
58
|
-
"wait.element": "selector",
|
|
59
|
-
"wait.url": "pattern",
|
|
60
|
-
zoom: "level",
|
|
61
|
-
"history.search": "query",
|
|
62
|
-
"network.get": "id",
|
|
63
|
-
"network.body": "id",
|
|
64
|
-
"network.curl": "id",
|
|
65
|
-
"network.path": "id",
|
|
66
|
-
"window.new": "url",
|
|
67
|
-
"window.focus": "id",
|
|
68
|
-
"window.close": "id",
|
|
69
|
-
"locate.role": "role",
|
|
70
|
-
"locate.text": "text",
|
|
71
|
-
"locate.label": "label",
|
|
72
|
-
"emulate.device": "device",
|
|
73
|
-
"frame.js": "code",
|
|
74
|
-
"element.styles": "selector",
|
|
75
|
-
"select": "selector",
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Tokenize a command line, respecting single and double quotes
|
|
80
|
-
* @param {string} line - Single line to tokenize
|
|
81
|
-
* @returns {string[]} - Array of tokens
|
|
82
|
-
*/
|
|
83
|
-
function tokenize(line) {
|
|
84
|
-
const tokens = [];
|
|
85
|
-
let current = '';
|
|
86
|
-
let inQuote = null;
|
|
87
|
-
|
|
88
|
-
for (let i = 0; i < line.length; i++) {
|
|
89
|
-
const ch = line[i];
|
|
90
|
-
|
|
91
|
-
if (inQuote) {
|
|
92
|
-
if (ch === inQuote) {
|
|
93
|
-
// End of quoted string
|
|
94
|
-
inQuote = null;
|
|
95
|
-
} else {
|
|
96
|
-
current += ch;
|
|
97
|
-
}
|
|
98
|
-
} else if (ch === '"' || ch === "'") {
|
|
99
|
-
// Start of quoted string
|
|
100
|
-
inQuote = ch;
|
|
101
|
-
} else if (ch === ' ' || ch === '\t') {
|
|
102
|
-
// Whitespace separator
|
|
103
|
-
if (current) {
|
|
104
|
-
tokens.push(current);
|
|
105
|
-
current = '';
|
|
106
|
-
}
|
|
107
|
-
} else {
|
|
108
|
-
current += ch;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Don't forget last token
|
|
113
|
-
if (current) {
|
|
114
|
-
tokens.push(current);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return tokens;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Parse a single command line into a step object
|
|
122
|
-
* @param {string} line - Single command line
|
|
123
|
-
* @returns {{ cmd: string, args: object } | null}
|
|
124
|
-
*/
|
|
125
|
-
function parseCommandLine(line) {
|
|
126
|
-
const tokens = tokenize(line);
|
|
127
|
-
if (tokens.length === 0) return null;
|
|
128
|
-
|
|
129
|
-
// Get command and apply alias
|
|
130
|
-
let cmd = tokens[0];
|
|
131
|
-
cmd = ALIASES[cmd] || cmd;
|
|
132
|
-
|
|
133
|
-
const args = {};
|
|
134
|
-
let i = 1;
|
|
135
|
-
|
|
136
|
-
// Handle first positional argument based on command type
|
|
137
|
-
if (i < tokens.length && !tokens[i].startsWith('--')) {
|
|
138
|
-
const firstArg = tokens[i];
|
|
139
|
-
|
|
140
|
-
// Special handling for click command
|
|
141
|
-
if (cmd === 'click') {
|
|
142
|
-
if (/^e\d+$/.test(firstArg)) {
|
|
143
|
-
// Element reference: e5 -> ref
|
|
144
|
-
args.ref = firstArg;
|
|
145
|
-
i++;
|
|
146
|
-
} else if (/^\d+$/.test(firstArg) && tokens[i + 1] && /^\d+$/.test(tokens[i + 1])) {
|
|
147
|
-
// Coordinates: 100 200 -> x, y
|
|
148
|
-
args.x = parseInt(firstArg, 10);
|
|
149
|
-
args.y = parseInt(tokens[i + 1], 10);
|
|
150
|
-
i += 2;
|
|
151
|
-
}
|
|
152
|
-
} else if (cmd === 'select') {
|
|
153
|
-
// Select takes selector + one or more values: select e5 "US" or select e5 "opt1" "opt2"
|
|
154
|
-
args.selector = firstArg;
|
|
155
|
-
i++;
|
|
156
|
-
// Collect remaining positional args as values
|
|
157
|
-
const values = [];
|
|
158
|
-
while (i < tokens.length && !tokens[i].startsWith('--')) {
|
|
159
|
-
values.push(tokens[i]);
|
|
160
|
-
i++;
|
|
161
|
-
}
|
|
162
|
-
// Host expects 'values' (always), matching CLI behavior
|
|
163
|
-
if (values.length === 1) {
|
|
164
|
-
args.values = values[0]; // Single value as string (host will wrap in array)
|
|
165
|
-
} else if (values.length > 1) {
|
|
166
|
-
args.values = values; // Multiple values as array
|
|
167
|
-
}
|
|
168
|
-
} else if (cmd === 'scroll') {
|
|
169
|
-
if (firstArg === 'top' || firstArg === 'bottom') {
|
|
170
|
-
cmd = `scroll.${firstArg}`;
|
|
171
|
-
i++;
|
|
172
|
-
} else if (['up', 'down', 'left', 'right'].includes(firstArg)) {
|
|
173
|
-
args.direction = firstArg;
|
|
174
|
-
i++;
|
|
175
|
-
if (i < tokens.length && /^-?\d+$/.test(tokens[i])) {
|
|
176
|
-
args.scroll_pixels = parseInt(tokens[i], 10);
|
|
177
|
-
i++;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
} else {
|
|
181
|
-
// Use PRIMARY_ARG_MAP for other commands
|
|
182
|
-
const primaryKey = PRIMARY_ARG_MAP[cmd];
|
|
183
|
-
if (primaryKey) {
|
|
184
|
-
args[primaryKey] = firstArg;
|
|
185
|
-
i++;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Parse --flag value pairs
|
|
191
|
-
while (i < tokens.length) {
|
|
192
|
-
const token = tokens[i];
|
|
193
|
-
if (token.startsWith('--')) {
|
|
194
|
-
const key = token.slice(2);
|
|
195
|
-
const next = tokens[i + 1];
|
|
196
|
-
if (next && !next.startsWith('--')) {
|
|
197
|
-
// Flag with value
|
|
198
|
-
let val = next;
|
|
199
|
-
// Type coercion
|
|
200
|
-
if (val === "true") val = true;
|
|
201
|
-
else if (val === "false") val = false;
|
|
202
|
-
else if (/^-?\d+$/.test(val)) val = parseInt(val, 10);
|
|
203
|
-
else if (/^-?\d+\.\d+$/.test(val)) val = parseFloat(val);
|
|
204
|
-
args[key] = val;
|
|
205
|
-
i += 2;
|
|
206
|
-
} else {
|
|
207
|
-
// Boolean flag
|
|
208
|
-
args[key] = true;
|
|
209
|
-
i++;
|
|
210
|
-
}
|
|
211
|
-
} else {
|
|
212
|
-
// Skip unrecognized positional (shouldn't happen normally)
|
|
213
|
-
i++;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
return { cmd, args };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Parse a workflow string into step array
|
|
222
|
-
* Supports pipe-separated (inline) or newline-separated (file) commands
|
|
223
|
-
* @param {string} input - Workflow string
|
|
224
|
-
* @returns {Array<{ cmd: string, args: object }>}
|
|
225
|
-
*/
|
|
226
|
-
function parseDoCommands(input) {
|
|
227
|
-
// Determine separator: use pipe if present, otherwise newlines
|
|
228
|
-
// Pipe is preferred for inline: 'go "url" | click e5 | screenshot'
|
|
229
|
-
// Newlines for files or heredocs
|
|
230
|
-
const hasPipe = input.includes('|');
|
|
231
|
-
const separator = hasPipe ? '|' : '\n';
|
|
232
|
-
|
|
233
|
-
// Also handle literal \n for backwards compatibility
|
|
234
|
-
const normalized = hasPipe ? input : input.replace(/\\n/g, '\n');
|
|
235
|
-
|
|
236
|
-
return normalized
|
|
237
|
-
.split(separator)
|
|
238
|
-
.map(line => line.trim())
|
|
239
|
-
.filter(line => line && !line.startsWith('#'))
|
|
240
|
-
.map(line => parseCommandLine(line))
|
|
241
|
-
.filter(step => step !== null);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
module.exports = {
|
|
245
|
-
parseDoCommands,
|
|
246
|
-
parseCommandLine,
|
|
247
|
-
tokenize,
|
|
248
|
-
ALIASES,
|
|
249
|
-
PRIMARY_ARG_MAP
|
|
1
|
+
const definition = require("./workflow-definition.cjs");
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
ALIASES: definition.ALIASES,
|
|
5
|
+
PRIMARY_ARG_MAP: definition.PRIMARY_ARG_MAP,
|
|
6
|
+
parseCommandLine: definition.parseCommandLine,
|
|
7
|
+
parseDoCommands: definition.parseDoCommands,
|
|
8
|
+
tokenize: definition.tokenize,
|
|
250
9
|
};
|
package/native/doctor.cjs
CHANGED
|
@@ -3,6 +3,7 @@ const net = require("net");
|
|
|
3
3
|
const os = require("os");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { execFileSync } = require("child_process");
|
|
6
|
+
const { connectEndpoint, selectEndpoint } = require("./endpoint.cjs");
|
|
6
7
|
|
|
7
8
|
const HOST_NAME = "surf.browser.host";
|
|
8
9
|
|
|
@@ -402,6 +403,16 @@ function buildRecommendations(report) {
|
|
|
402
403
|
return Array.from(new Set(recommendations));
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
function remoteRecommendations(endpoint, code) {
|
|
407
|
+
const base = [`Confirm Surf is listening on ${endpoint.display}; the remote host listener must allow this Tailnet connection.`];
|
|
408
|
+
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
|
+
if (code === "ETIMEDOUT") return [...base, `Run \`tailscale ping ${endpoint.host}\`; check restrictive Tailnet ACLs/grants and host firewall rules.`];
|
|
410
|
+
if (code === "ECONNREFUSED") return [...base, "Verify the host process is running and bound to the requested TCP port; check restrictive Tailnet ACLs/grants."];
|
|
411
|
+
if (code === "ENETUNREACH" || code === "EHOSTUNREACH") return [...base, `Run \`tailscale status\` and \`tailscale ping ${endpoint.host}\`; check Tailnet routing plus restrictive ACLs/grants.`];
|
|
412
|
+
if (code === "EAUTH") return [...base, "Verify the client credential path, pinned host identity, and that the labeled client has not been revoked."].map((item) => item.replace("<host>", endpoint.host));
|
|
413
|
+
return base;
|
|
414
|
+
}
|
|
415
|
+
|
|
405
416
|
async function runDoctor(rawOptions = {}, deps = {}) {
|
|
406
417
|
const env = deps.env || process.env;
|
|
407
418
|
const platform = deps.platform || process.platform;
|
|
@@ -413,6 +424,41 @@ async function runDoctor(rawOptions = {}, deps = {}) {
|
|
|
413
424
|
socket: rawOptions.socket || env.SURF_SOCKET || defaultSocketPath(platform),
|
|
414
425
|
connectTimeoutMs: rawOptions.connectTimeoutMs ?? 750,
|
|
415
426
|
};
|
|
427
|
+
const selectedEndpoint = rawOptions.endpoint || selectEndpoint([], env).endpoint;
|
|
428
|
+
if (selectedEndpoint.kind === "remote") {
|
|
429
|
+
const endpoint = selectedEndpoint;
|
|
430
|
+
const connection = await (deps.connectEndpoint || ((target, timeoutMs) => new Promise((resolve) => {
|
|
431
|
+
let settled = false;
|
|
432
|
+
let socket;
|
|
433
|
+
const finish = (result) => {
|
|
434
|
+
if (settled) return;
|
|
435
|
+
settled = true;
|
|
436
|
+
clearTimeout(timeout);
|
|
437
|
+
socket?.destroy();
|
|
438
|
+
resolve(result);
|
|
439
|
+
};
|
|
440
|
+
const timeout = setTimeout(() => finish({
|
|
441
|
+
ok: false,
|
|
442
|
+
code: socket?.connected ? "EAUTH" : "ETIMEDOUT",
|
|
443
|
+
message: socket?.connected ? "remote authentication timed out" : `timed out after ${timeoutMs}ms`,
|
|
444
|
+
}), timeoutMs);
|
|
445
|
+
socket = connectEndpoint(target, () => finish({ ok: true, message: "authenticated" }));
|
|
446
|
+
socket.once("error", (error) => finish({ ok: false, code: error.code, message: error.message || String(error) }));
|
|
447
|
+
})))(endpoint, options.connectTimeoutMs);
|
|
448
|
+
const checks = [{ id: "remote-endpoint", status: "info", message: `Remote endpoint: ${endpoint.display}`, endpoint: endpoint.display }, {
|
|
449
|
+
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}`,
|
|
451
|
+
code: connection.code,
|
|
452
|
+
}, {
|
|
453
|
+
id: "remote-auth", status: connection.ok ? "pass" : connection.code === "EAUTH" ? "fail" : "info",
|
|
454
|
+
message: connection.ok ? "Remote credential authenticated and server identity verified" : connection.code === "EAUTH" ? "Remote credential rejected, revoked, or server identity mismatch" : "Remote authentication was not reached",
|
|
455
|
+
code: connection.code,
|
|
456
|
+
}];
|
|
457
|
+
const summary = summarize(checks);
|
|
458
|
+
const report = { ok: connection.ok, summary, environment: { platform, remoteEndpoint: endpoint.display, endpointKind: "remote", browsers: [] }, manifests: [], checks };
|
|
459
|
+
report.recommendations = connection.ok ? [] : remoteRecommendations(endpoint, connection.code);
|
|
460
|
+
return report;
|
|
461
|
+
}
|
|
416
462
|
const effectiveTarget = resolveEffectiveTarget(options, { platform, runningInWsl });
|
|
417
463
|
const browsers = resolveBrowsers(options.browser);
|
|
418
464
|
const context = {
|
|
@@ -502,9 +548,13 @@ function formatCheck(check) {
|
|
|
502
548
|
function formatDoctorReport(report) {
|
|
503
549
|
const lines = ["Surf doctor", ""];
|
|
504
550
|
lines.push(`Platform: ${report.environment.platform}${report.environment.runningInWsl ? " (WSL2 detected)" : ""}`);
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
551
|
+
if (report.environment.endpointKind === "remote") {
|
|
552
|
+
lines.push(`Remote endpoint: ${report.environment.remoteEndpoint}`);
|
|
553
|
+
} else {
|
|
554
|
+
lines.push(`Target: ${report.environment.effectiveTarget === "wsl-windows" ? "Windows browser from WSL2" : report.environment.effectiveTarget}`);
|
|
555
|
+
}
|
|
556
|
+
if (report.environment.socketPath) lines.push(`Socket: ${report.environment.socketPath}`);
|
|
557
|
+
if (report.environment.browsers.length) lines.push(`Browsers: ${report.environment.browsers.join(", ")}`);
|
|
508
558
|
lines.push("");
|
|
509
559
|
|
|
510
560
|
for (const check of report.checks.filter((item) => item.status !== "info")) {
|
|
@@ -542,7 +592,7 @@ Examples:
|
|
|
542
592
|
`;
|
|
543
593
|
}
|
|
544
594
|
|
|
545
|
-
async function runDoctorCli(rawArgs) {
|
|
595
|
+
async function runDoctorCli(rawArgs, endpoint) {
|
|
546
596
|
let options;
|
|
547
597
|
try {
|
|
548
598
|
options = parseDoctorArgs(rawArgs);
|
|
@@ -558,7 +608,7 @@ async function runDoctorCli(rawArgs) {
|
|
|
558
608
|
}
|
|
559
609
|
|
|
560
610
|
try {
|
|
561
|
-
const report = await runDoctor(options);
|
|
611
|
+
const report = await runDoctor({ ...options, endpoint });
|
|
562
612
|
if (options.json) {
|
|
563
613
|
console.log(JSON.stringify(report, null, 2));
|
|
564
614
|
} else {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
const net = require("net");
|
|
2
|
+
const { DEFAULT_SOCKET_PATH } = require("./socket-path.cjs");
|
|
3
|
+
const { authenticateClient } = require("./remote-transport.cjs");
|
|
4
|
+
|
|
5
|
+
function parseRemoteEndpoint(value) {
|
|
6
|
+
if (typeof value !== "string" || !value) throw new Error("--remote requires host:port");
|
|
7
|
+
let host;
|
|
8
|
+
let portText;
|
|
9
|
+
if (value.startsWith("[")) {
|
|
10
|
+
const match = value.match(/^\[([^\]]+)\]:(\d+)$/);
|
|
11
|
+
if (!match || net.isIP(match?.[1]) !== 6) throw new Error("remote endpoint must use a bracketed IPv6 address and port");
|
|
12
|
+
[, host, portText] = match;
|
|
13
|
+
host = new URL(`http://[${host}]`).hostname.slice(1, -1);
|
|
14
|
+
} else {
|
|
15
|
+
const match = value.match(/^([^:]+):(\d+)$/);
|
|
16
|
+
if (!match) throw new Error("remote endpoint must be host:port (IPv6 must be bracketed)");
|
|
17
|
+
[, host, portText] = match;
|
|
18
|
+
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 && !/^(?=.{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])?$/.test(host))) {
|
|
20
|
+
throw new Error("remote endpoint host is invalid");
|
|
21
|
+
}
|
|
22
|
+
host = host.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
const port = Number(portText);
|
|
25
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("remote endpoint port must be between 1 and 65535");
|
|
26
|
+
if ((net.isIP(host) === 4 && host === "0.0.0.0") || (net.isIP(host) === 6 && /^0*:?0*$/.test(host.replace(/:/g, "")))) {
|
|
27
|
+
throw new Error("remote endpoint host must not be unspecified");
|
|
28
|
+
}
|
|
29
|
+
const display = net.isIP(host) === 6 ? `[${host}]:${port}` : `${host}:${port}`;
|
|
30
|
+
return { kind: "remote", host, port, display, key: `tcp:${display}`, connectionOptions: { host, port } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function selectEndpoint(args, env) {
|
|
34
|
+
const selectedEnv = env === undefined ? process.env : env;
|
|
35
|
+
const remoteIndexes = [];
|
|
36
|
+
const credentialIndexes = [];
|
|
37
|
+
for (let i = 0; i < args.length; i++) {
|
|
38
|
+
if (args[i] === "--remote") remoteIndexes.push(i);
|
|
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);
|
|
51
|
+
}
|
|
52
|
+
if (credentialIndexes.length) {
|
|
53
|
+
const index = credentialIndexes[0];
|
|
54
|
+
cliCredential = args[index + 1];
|
|
55
|
+
if (!cliCredential || cliCredential.startsWith("--")) throw new Error("--remote-credential requires a file path");
|
|
56
|
+
const adjustedIndex = index - (remoteIndexes.length && index > remoteIndexes[0] ? 2 : 0);
|
|
57
|
+
strippedArgs.splice(adjustedIndex, 2);
|
|
58
|
+
}
|
|
59
|
+
const remoteValue = cliRemote || selectedEnv.SURF_REMOTE;
|
|
60
|
+
if (remoteValue) {
|
|
61
|
+
const credentialPath = cliCredential || selectedEnv.SURF_REMOTE_CREDENTIAL;
|
|
62
|
+
if (!credentialPath) throw new Error("remote endpoint requires --remote-credential <path> or SURF_REMOTE_CREDENTIAL");
|
|
63
|
+
return { args: strippedArgs, endpoint: { ...parseRemoteEndpoint(remoteValue), credentialPath } };
|
|
64
|
+
}
|
|
65
|
+
if (cliCredential) throw new Error("--remote-credential requires a remote endpoint");
|
|
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 } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createRemoteSocket(endpoint) {
|
|
71
|
+
const rawSocket = net.createConnection(endpoint.connectionOptions, () => {});
|
|
72
|
+
let ready = false;
|
|
73
|
+
let connected = false;
|
|
74
|
+
let destroyed = false;
|
|
75
|
+
const pending = new Map();
|
|
76
|
+
const queue = (event, listener, once) => {
|
|
77
|
+
if (ready) {
|
|
78
|
+
once ? rawSocket.once(event, listener) : rawSocket.on(event, listener);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const listeners = pending.get(event) || [];
|
|
82
|
+
listeners.push({ listener, once });
|
|
83
|
+
pending.set(event, listeners);
|
|
84
|
+
};
|
|
85
|
+
const flush = () => {
|
|
86
|
+
ready = true;
|
|
87
|
+
for (const [event, listeners] of pending) {
|
|
88
|
+
for (const { listener, once } of listeners) {
|
|
89
|
+
once ? rawSocket.once(event, listener) : rawSocket.on(event, listener);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
pending.clear();
|
|
93
|
+
};
|
|
94
|
+
const proxy = {
|
|
95
|
+
on(event, listener) { queue(event, listener, false); return proxy; },
|
|
96
|
+
once(event, listener) { queue(event, listener, true); return proxy; },
|
|
97
|
+
removeListener(event, listener) {
|
|
98
|
+
if (ready) rawSocket.removeListener(event, listener);
|
|
99
|
+
else pending.set(event, (pending.get(event) || []).filter((entry) => entry.listener !== listener));
|
|
100
|
+
return proxy;
|
|
101
|
+
},
|
|
102
|
+
write(...args) { return rawSocket.write(...args); },
|
|
103
|
+
end(...args) { return rawSocket.end(...args); },
|
|
104
|
+
destroy(...args) { destroyed = true; return rawSocket.destroy(...args); },
|
|
105
|
+
setTimeout(...args) { rawSocket.setTimeout(...args); return proxy; },
|
|
106
|
+
get authenticated() { return ready; },
|
|
107
|
+
get connected() { return connected; },
|
|
108
|
+
};
|
|
109
|
+
rawSocket.once("connect", () => { connected = true; });
|
|
110
|
+
rawSocket.on("error", (error) => {
|
|
111
|
+
if (ready || destroyed) return;
|
|
112
|
+
ready = true;
|
|
113
|
+
const listeners = pending.get("error") || [];
|
|
114
|
+
pending.delete("error");
|
|
115
|
+
for (const { listener } of listeners) listener(error);
|
|
116
|
+
if (proxy.__pendingErrors) proxy.__pendingErrors.length = 0;
|
|
117
|
+
flush();
|
|
118
|
+
});
|
|
119
|
+
rawSocket.on("close", () => {
|
|
120
|
+
if (ready) return;
|
|
121
|
+
ready = true;
|
|
122
|
+
const error = new Error("remote authentication connection closed");
|
|
123
|
+
for (const { listener } of pending.get("error") || []) listener(error);
|
|
124
|
+
for (const { listener } of pending.get("close") || []) listener();
|
|
125
|
+
pending.clear();
|
|
126
|
+
if (proxy.__pendingErrors) proxy.__pendingErrors.length = 0;
|
|
127
|
+
});
|
|
128
|
+
return { rawSocket, proxy, flush };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function connectEndpoint(endpoint, onConnect) {
|
|
132
|
+
if (endpoint.kind === "local") {
|
|
133
|
+
return net.createConnection(endpoint.connectionOptions, onConnect || (() => {}));
|
|
134
|
+
}
|
|
135
|
+
const { rawSocket, proxy, flush } = createRemoteSocket(endpoint);
|
|
136
|
+
rawSocket.once("connect", () => {
|
|
137
|
+
authenticateClient(rawSocket, endpoint.credentialPath)
|
|
138
|
+
.then(() => {
|
|
139
|
+
flush();
|
|
140
|
+
if (onConnect) onConnect(proxy);
|
|
141
|
+
})
|
|
142
|
+
.catch((error) => {
|
|
143
|
+
error.code = error.code || "EAUTH";
|
|
144
|
+
const listeners = proxy.__pendingErrors || [];
|
|
145
|
+
for (const listener of listeners) {
|
|
146
|
+
proxy.removeListener("error", listener);
|
|
147
|
+
listener(error);
|
|
148
|
+
}
|
|
149
|
+
proxy.__pendingErrors.length = 0;
|
|
150
|
+
flush();
|
|
151
|
+
proxy.destroy();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
proxy.__pendingErrors = [];
|
|
155
|
+
const originalOn = proxy.on;
|
|
156
|
+
const originalOnce = proxy.once;
|
|
157
|
+
proxy.on = (event, listener) => {
|
|
158
|
+
if (event === "error" && !proxy.authenticated) proxy.__pendingErrors.push(listener);
|
|
159
|
+
return originalOn(event, listener);
|
|
160
|
+
};
|
|
161
|
+
proxy.once = (event, listener) => {
|
|
162
|
+
if (event === "error" && !proxy.authenticated) proxy.__pendingErrors.push(listener);
|
|
163
|
+
return originalOnce(event, listener);
|
|
164
|
+
};
|
|
165
|
+
return proxy;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function formatEndpointError(error, endpoint, formatSocketError) {
|
|
169
|
+
if (endpoint.kind === "local") return formatSocketError(error);
|
|
170
|
+
const message = error?.message || String(error);
|
|
171
|
+
return `Remote endpoint connection failed (${endpoint.display}): ${message}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { parseRemoteEndpoint, selectEndpoint, connectEndpoint, formatEndpointError };
|