@zswarm/core 0.1.7 → 0.1.9

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.
@@ -0,0 +1,1179 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { connect, createServer } from "node:net";
4
+ import { ZellijError } from "../errors.js";
5
+ import { parseSshOpts } from "../zellij/binary.js";
6
+ import { fail } from "./util.js";
7
+ import { callServe, formatListenLabel, parseListenAddress, probeServe, } from "./serve.js";
8
+ export const DEFAULT_SSH_SERVE_PORT = parseListenAddress(undefined).port;
9
+ /** Ordinary OpenSSH default when neither the URI nor ssh_config supplies Port. */
10
+ export const DEFAULT_SSH_PORT = 22;
11
+ export const SSH_SERVE_REMOTE_HOST = "127.0.0.1";
12
+ export const SSH_TUNNEL_KEEPALIVE_INTERVAL_S = 15;
13
+ export const SSH_TUNNEL_KEEPALIVE_COUNT = 3;
14
+ export const SSH_TUNNEL_PORT_RETRIES = 8;
15
+ const SSH_STDERR_CAP = 4_096;
16
+ const TCP_PROBE_MS = 200;
17
+ const SPAWN_RETRY_MS = 50;
18
+ const CONTROLLER_ROUTING_FIELDS = ["serveAddress", "local", "ssh"];
19
+ const CREDENTIAL_ENV = ["SSH_AUTH_SOCK", "SSH_ASKPASS", "SSH_AGENT_PID"];
20
+ /** Short flags that consume the following argv token (or the rest of a cluster). */
21
+ const SSH_FLAGS_WITH_ARG = new Set([
22
+ "b",
23
+ "c",
24
+ "D",
25
+ "E",
26
+ "e",
27
+ "F",
28
+ "I",
29
+ "i",
30
+ "J",
31
+ "L",
32
+ "l",
33
+ "m",
34
+ "O",
35
+ "o",
36
+ "p",
37
+ "Q",
38
+ "R",
39
+ "S",
40
+ "W",
41
+ "w",
42
+ ]);
43
+ export function isSshServeTarget(raw) {
44
+ return /^\s*ssh:\/\//i.test(raw);
45
+ }
46
+ /**
47
+ * Safe label for logs/errors. Successful parses use the canonical URI.
48
+ * Failed parses omit userinfo, query, path, and fragment so secrets cannot
49
+ * echo through diagnostics.
50
+ */
51
+ export function describeServeTarget(raw) {
52
+ const trimmed = raw.trim();
53
+ if (!isSshServeTarget(trimmed))
54
+ return trimmed;
55
+ try {
56
+ return formatSshServeTarget(parseSshServeTarget(trimmed));
57
+ }
58
+ catch {
59
+ return diagnosticSshTarget(trimmed);
60
+ }
61
+ }
62
+ /** @deprecated Use describeServeTarget; kept for callers that redacted userinfo only. */
63
+ export function redactSshUserinfo(raw) {
64
+ return diagnosticSshTarget(raw);
65
+ }
66
+ export function formatSshServeTarget(target) {
67
+ const host = target.host.includes(":") ? `[${target.host}]` : target.host;
68
+ const auth = target.user ? `${encodeURIComponent(target.user)}@${host}` : host;
69
+ const port = target.sshPort != null ? `:${target.sshPort}` : "";
70
+ return `ssh://${auth}${port}?servePort=${target.servePort}`;
71
+ }
72
+ function diagnosticSshTarget(raw) {
73
+ const trimmed = raw.trim();
74
+ if (!isSshServeTarget(trimmed))
75
+ return "ssh://";
76
+ try {
77
+ const url = new URL(trimmed);
78
+ if (url.protocol.toLowerCase() !== "ssh:")
79
+ return "ssh://";
80
+ const host = url.hostname.trim().replace(/^\[|\]$/g, "");
81
+ if (!host || !isSafeDiagnosticHost(host))
82
+ return "ssh://";
83
+ const shown = host.includes(":") ? `[${host}]` : host;
84
+ return `ssh://${shown}`;
85
+ }
86
+ catch {
87
+ return "ssh://";
88
+ }
89
+ }
90
+ function isSafeDiagnosticHost(host) {
91
+ if (!host || host.startsWith("-") || /[\s;|&$<>()?#/%\u0000-\u001f\u007f]/.test(host)) {
92
+ return false;
93
+ }
94
+ return true;
95
+ }
96
+ function invalid(message, raw) {
97
+ throw new ZellijError("bad_arg", `${message} (${diagnosticSshTarget(raw)})`);
98
+ }
99
+ function rejectSshDestinationPart(value, label, raw) {
100
+ if (!value)
101
+ invalid(`ssh:// serve target is missing a ${label}`, raw);
102
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
103
+ invalid("ssh:// serve target contains control characters", raw);
104
+ }
105
+ if (value.startsWith("-") || /^ssh(\s|$)/i.test(value) || /\s/.test(value) || /[;|&$<>()]/.test(value)) {
106
+ invalid("ssh:// destination is not a valid SSH destination", raw);
107
+ }
108
+ }
109
+ function parsePort(value, label, raw) {
110
+ if (!/^\d+$/.test(value))
111
+ invalid(`ssh:// ${label} is invalid`, raw);
112
+ const port = Number(value);
113
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
114
+ invalid(`ssh:// ${label} is invalid`, raw);
115
+ }
116
+ return port;
117
+ }
118
+ function rejectUnsafeEncoding(raw) {
119
+ if (/[\u0000-\u001f\u007f]/.test(raw)) {
120
+ invalid("ssh:// serve target contains control characters", raw);
121
+ }
122
+ for (let i = 0; i < raw.length; i++) {
123
+ if (raw[i] !== "%")
124
+ continue;
125
+ const hex = raw.slice(i + 1, i + 3);
126
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) {
127
+ invalid("ssh:// serve target contains a malformed escape", raw);
128
+ }
129
+ const code = Number.parseInt(hex, 16);
130
+ if (code <= 0x1f || code === 0x7f) {
131
+ invalid("ssh:// serve target contains control characters", raw);
132
+ }
133
+ }
134
+ }
135
+ function looksLikePasswordUserinfo(raw) {
136
+ const trimmed = raw.trim();
137
+ const rest = trimmed.replace(/^ssh:\/\//i, "");
138
+ const authority = rest.split(/[/?#]/, 1)[0] ?? "";
139
+ const at = authority.lastIndexOf("@");
140
+ if (at <= 0)
141
+ return false;
142
+ return authority.slice(0, at).includes(":");
143
+ }
144
+ /**
145
+ * ssh:// is not host:port. Authority port is the SSH port when present; omitting
146
+ * it leaves Port to OpenSSH (alias ssh_config, then ssh's own default).
147
+ * `servePort` is the already-running loopback serve on the remote (default 9419).
148
+ */
149
+ export function parseSshServeTarget(raw) {
150
+ const trimmed = raw.trim();
151
+ if (!isSshServeTarget(trimmed)) {
152
+ invalid("serve ssh target must be an ssh:// URI", trimmed);
153
+ }
154
+ rejectUnsafeEncoding(trimmed);
155
+ if (trimmed.includes("#")) {
156
+ invalid("ssh:// serve target must not include a fragment", trimmed);
157
+ }
158
+ if (looksLikePasswordUserinfo(trimmed) || /\/\/[^/?#]*:[^@/?#]*@/.test(trimmed)) {
159
+ throw new ZellijError("bad_arg", "ssh:// serve target must not include a password (redacted)");
160
+ }
161
+ let url;
162
+ try {
163
+ url = new URL(trimmed);
164
+ }
165
+ catch {
166
+ invalid("ssh:// serve target is not a valid URI", trimmed);
167
+ }
168
+ if (url.protocol.toLowerCase() !== "ssh:") {
169
+ invalid("serve ssh target must be an ssh:// URI", trimmed);
170
+ }
171
+ if (url.password) {
172
+ throw new ZellijError("bad_arg", "ssh:// serve target must not include a password (redacted)");
173
+ }
174
+ if (url.hash) {
175
+ invalid("ssh:// serve target must not include a fragment", trimmed);
176
+ }
177
+ const path = url.pathname === "/" || url.pathname === "" ? "" : url.pathname;
178
+ if (path) {
179
+ invalid("ssh:// serve target must not include a path", trimmed);
180
+ }
181
+ const keys = [...url.searchParams.keys()];
182
+ for (const key of keys) {
183
+ if (key !== "servePort") {
184
+ invalid("ssh:// serve target has an unsupported query", trimmed);
185
+ }
186
+ }
187
+ if (url.searchParams.getAll("servePort").length > 1) {
188
+ invalid("ssh:// serve target must not repeat servePort", trimmed);
189
+ }
190
+ const host = url.hostname.trim().replace(/^\[|\]$/g, "");
191
+ rejectSshDestinationPart(host, "host", trimmed);
192
+ const sshPort = url.port ? parsePort(url.port, "SSH port", trimmed) : undefined;
193
+ let servePort = DEFAULT_SSH_SERVE_PORT;
194
+ if (url.searchParams.has("servePort")) {
195
+ servePort = parsePort(url.searchParams.get("servePort") ?? "", "servePort", trimmed);
196
+ }
197
+ let user;
198
+ if (url.username) {
199
+ try {
200
+ user = decodeURIComponent(url.username);
201
+ }
202
+ catch {
203
+ invalid("ssh:// username is not valid", trimmed);
204
+ }
205
+ if (!user || /[\s;|&$<>()@]/.test(user) || /[\u0000-\u001f\u007f]/.test(user)) {
206
+ invalid("ssh:// username contains invalid characters", trimmed);
207
+ }
208
+ if (user.startsWith("-")) {
209
+ invalid("ssh:// destination is not a valid SSH destination", trimmed);
210
+ }
211
+ }
212
+ const destination = user ? `${user}@${host}` : host;
213
+ rejectSshDestinationPart(destination.includes("@") ? host : destination, "host", trimmed);
214
+ if (destination.startsWith("-")) {
215
+ invalid("ssh:// destination is not a valid SSH destination", trimmed);
216
+ }
217
+ return { user, host, sshPort, servePort, destination };
218
+ }
219
+ function parseOptionKeyword(raw) {
220
+ const trimmed = raw.trim();
221
+ const sep = trimmed.search(/[=\s]/);
222
+ if (sep === -1)
223
+ return { name: trimmed.toLowerCase(), value: "" };
224
+ return {
225
+ name: trimmed.slice(0, sep).trim().toLowerCase(),
226
+ value: trimmed.slice(sep + 1).trim().toLowerCase(),
227
+ };
228
+ }
229
+ function sshOptionAt(opts, index) {
230
+ const arg = opts[index];
231
+ let raw = "";
232
+ let consumed = 0;
233
+ if (arg === "-o") {
234
+ raw = opts[index + 1] ?? "";
235
+ consumed = 2;
236
+ }
237
+ else if (arg.startsWith("-o") && arg.length > 2) {
238
+ raw = arg.slice(2);
239
+ consumed = 1;
240
+ }
241
+ else {
242
+ return null;
243
+ }
244
+ const parsed = parseOptionKeyword(raw);
245
+ return { name: parsed.name, value: parsed.value, consumed };
246
+ }
247
+ function isDisabled(value) {
248
+ return value === "" || value === "no" || value === "off" || value === "false" || value === "0";
249
+ }
250
+ function rejectForbiddenKeyword(name, value) {
251
+ if (name === "controlmaster" && !isDisabled(value)) {
252
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS ControlMaster=${value || "yes"} would outlive the tracked ssh child; zswarm uses a foreground-owned tunnel`);
253
+ }
254
+ if (name === "controlpersist" && !isDisabled(value)) {
255
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS ControlPersist=${value || "yes"} would outlive the tracked ssh child`);
256
+ }
257
+ if (name === "controlpath" && value !== "" && value !== "none") {
258
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not set ControlPath; zswarm uses ControlPath=none for a private foreground child");
259
+ }
260
+ if (name === "forkafterauthentication" && !isDisabled(value)) {
261
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not fork the ssh child");
262
+ }
263
+ if (name === "exitonforwardfailure" && isDisabled(value) && value !== "") {
264
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not disable ExitOnForwardFailure");
265
+ }
266
+ if (name === "batchmode" && isDisabled(value) && value !== "") {
267
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not disable BatchMode; ssh:// tunnels are noninteractive");
268
+ }
269
+ if (name === "remotecommand" ||
270
+ name === "localcommand" ||
271
+ name === "permitlocalcommand" ||
272
+ name === "localforward" ||
273
+ name === "remoteforward" ||
274
+ name === "dynamicforward") {
275
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS ${name} is incompatible with a process-owned LocalForward`);
276
+ }
277
+ }
278
+ function rejectFlagLetter(flag) {
279
+ if (flag === "f") {
280
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not daemonize ssh (-f); zswarm owns a foreground LocalForward child");
281
+ }
282
+ if (flag === "M") {
283
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not enable ControlMaster (-M); zswarm uses a foreground-owned tunnel");
284
+ }
285
+ if (flag === "L" || flag === "R" || flag === "D") {
286
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not add extra forwards; zswarm owns -L");
287
+ }
288
+ if (flag === "p") {
289
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not set -p; put an explicit SSH port on the ssh:// URI or in ssh_config");
290
+ }
291
+ if (flag === "W" || flag === "O" || flag === "t" || flag === "V" || flag === "G" || flag === "s") {
292
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS contains ssh flag -${flag}, which is incompatible with a process-owned LocalForward`);
293
+ }
294
+ }
295
+ /**
296
+ * Reject ControlMaster/daemonize/fork/extra forwards/remote commands that would
297
+ * outlive or escape the tracked foreground child. Combined clusters (`-fn`) and
298
+ * whitespace `-o Keyword Value` forms are parsed, not only `-o Keyword=value`.
299
+ */
300
+ export function assertSafeSshTunnelOpts(opts) {
301
+ for (let i = 0; i < opts.length; i++) {
302
+ const arg = opts[i];
303
+ if (arg === "--" || arg.startsWith("--")) {
304
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not include a destination or remote command");
305
+ }
306
+ if (arg === "-" || !arg.startsWith("-")) {
307
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not include a destination or remote command");
308
+ }
309
+ const option = sshOptionAt(opts, i);
310
+ if (option) {
311
+ rejectForbiddenKeyword(option.name, option.value);
312
+ i += option.consumed - 1;
313
+ continue;
314
+ }
315
+ if (arg === "-S" || arg.startsWith("-S")) {
316
+ const value = arg === "-S" ? (opts[++i] ?? "") : arg.slice(2);
317
+ if (value.trim().toLowerCase() !== "none") {
318
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not set ControlPath; zswarm uses -S none");
319
+ }
320
+ continue;
321
+ }
322
+ if (arg.length === 2) {
323
+ const flag = arg[1];
324
+ rejectFlagLetter(flag);
325
+ if (SSH_FLAGS_WITH_ARG.has(flag)) {
326
+ if (opts[i + 1] === undefined) {
327
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS ${arg} is missing an argument`);
328
+ }
329
+ i += 1;
330
+ }
331
+ continue;
332
+ }
333
+ // Combined short flags, e.g. -fn / -NTf, or attached-arg forms like -iKEY.
334
+ // Argument-taking flags consume the rest of the cluster or the next argv
335
+ // token and validate -o keywords (so -voLocalForward=… cannot skip the guard).
336
+ let j = 1;
337
+ while (j < arg.length) {
338
+ const flag = arg[j];
339
+ rejectFlagLetter(flag);
340
+ if (SSH_FLAGS_WITH_ARG.has(flag)) {
341
+ let value;
342
+ if (j + 1 < arg.length) {
343
+ value = arg.slice(j + 1);
344
+ }
345
+ else if (opts[i + 1] === undefined) {
346
+ throw new ZellijError("bad_ssh", `ZSWARM_SSH_OPTS -${flag} is missing an argument`);
347
+ }
348
+ else {
349
+ value = opts[++i];
350
+ }
351
+ if (flag === "o") {
352
+ const parsed = parseOptionKeyword(value);
353
+ rejectForbiddenKeyword(parsed.name, parsed.value);
354
+ }
355
+ else if (flag === "S" && value.trim().toLowerCase() !== "none") {
356
+ throw new ZellijError("bad_ssh", "ZSWARM_SSH_OPTS must not set ControlPath; zswarm uses -S none");
357
+ }
358
+ break;
359
+ }
360
+ j += 1;
361
+ }
362
+ }
363
+ }
364
+ /**
365
+ * Required `-o` keywords come first so OpenSSH's first-obtained-value rule
366
+ * cannot be overridden by later user `-o` or ssh_config. `-S none` is last so
367
+ * a last-assignment ControlPath flag still loses. Explicit URI `-p` is emitted
368
+ * only when the URI supplied a port; omitted port leaves alias Port intact.
369
+ */
370
+ export function buildSshTunnelArgv(target, localPort, env = {}) {
371
+ const bin = env.ZSWARM_SSH_BIN?.trim() || "ssh";
372
+ const userOpts = parseSshOpts(env.ZSWARM_SSH_OPTS ?? "");
373
+ assertSafeSshTunnelOpts(userOpts);
374
+ const args = [
375
+ "-N",
376
+ "-T",
377
+ "-o",
378
+ "BatchMode=yes",
379
+ "-o",
380
+ "ExitOnForwardFailure=yes",
381
+ "-o",
382
+ `ServerAliveInterval=${SSH_TUNNEL_KEEPALIVE_INTERVAL_S}`,
383
+ "-o",
384
+ `ServerAliveCountMax=${SSH_TUNNEL_KEEPALIVE_COUNT}`,
385
+ "-o",
386
+ "ControlMaster=no",
387
+ "-o",
388
+ "ControlPath=none",
389
+ "-o",
390
+ "ControlPersist=no",
391
+ "-o",
392
+ "ForkAfterAuthentication=no",
393
+ ...userOpts,
394
+ "-S",
395
+ "none",
396
+ "-N",
397
+ "-T",
398
+ "-o",
399
+ "ForkAfterAuthentication=no",
400
+ "-L",
401
+ `${SSH_SERVE_REMOTE_HOST}:${localPort}:${SSH_SERVE_REMOTE_HOST}:${target.servePort}`,
402
+ ];
403
+ if (target.sshPort != null) {
404
+ args.push("-p", String(target.sshPort));
405
+ }
406
+ args.push(target.destination);
407
+ return { bin, args };
408
+ }
409
+ function fingerprint(value) {
410
+ return createHash("sha256").update(value).digest("hex");
411
+ }
412
+ function credentialFingerprints(env) {
413
+ const out = {};
414
+ for (const name of CREDENTIAL_ENV) {
415
+ const raw = env[name];
416
+ out[name] = raw == null ? null : fingerprint(raw);
417
+ }
418
+ return out;
419
+ }
420
+ /** Isolated identity for an owned tunnel. Never includes the serve token or raw secrets. */
421
+ export function serveTunnelCacheKey(target, env = {}) {
422
+ return JSON.stringify({
423
+ bin: env.ZSWARM_SSH_BIN?.trim() || "ssh",
424
+ opts: fingerprint(JSON.stringify(parseSshOpts(env.ZSWARM_SSH_OPTS ?? ""))),
425
+ destination: target.destination,
426
+ sshPort: target.sshPort ?? null,
427
+ serveHost: SSH_SERVE_REMOTE_HOST,
428
+ servePort: target.servePort,
429
+ credentials: credentialFingerprints(env),
430
+ });
431
+ }
432
+ export function allocateLoopbackPort() {
433
+ return new Promise((resolve, reject) => {
434
+ const server = createServer();
435
+ server.on("error", reject);
436
+ server.listen(0, SSH_SERVE_REMOTE_HOST, () => {
437
+ const addr = server.address();
438
+ const port = typeof addr === "object" && addr ? addr.port : 0;
439
+ server.close((err) => {
440
+ if (err)
441
+ reject(err);
442
+ else if (!Number.isInteger(port) || port < 1) {
443
+ reject(new ZellijError("failed", "failed to allocate a private loopback port"));
444
+ }
445
+ else
446
+ resolve(port);
447
+ });
448
+ });
449
+ });
450
+ }
451
+ function childAlive(child) {
452
+ return child.exitCode === null && child.signalCode === null;
453
+ }
454
+ function defaultSleep(ms, signal) {
455
+ return new Promise((resolve, reject) => {
456
+ if (signal?.aborted) {
457
+ reject(new ZellijError("cancelled", "operation cancelled"));
458
+ return;
459
+ }
460
+ const timer = setTimeout(() => {
461
+ signal?.removeEventListener("abort", onAbort);
462
+ resolve();
463
+ }, ms);
464
+ const onAbort = () => {
465
+ clearTimeout(timer);
466
+ reject(new ZellijError("cancelled", "operation cancelled"));
467
+ };
468
+ signal?.addEventListener("abort", onAbort, { once: true });
469
+ });
470
+ }
471
+ function waitForTcp(port, timeoutMs, signal) {
472
+ return new Promise((resolve) => {
473
+ if (signal?.aborted || timeoutMs <= 0) {
474
+ resolve(false);
475
+ return;
476
+ }
477
+ const socket = connect({ host: SSH_SERVE_REMOTE_HOST, port });
478
+ const timer = setTimeout(() => finish(false), Math.max(1, timeoutMs));
479
+ const finish = (ok) => {
480
+ clearTimeout(timer);
481
+ signal?.removeEventListener("abort", onAbort);
482
+ socket.removeAllListeners();
483
+ socket.destroy();
484
+ resolve(ok);
485
+ };
486
+ const onAbort = () => finish(false);
487
+ socket.once("connect", () => finish(true));
488
+ socket.once("error", () => finish(false));
489
+ signal?.addEventListener("abort", onAbort, { once: true });
490
+ });
491
+ }
492
+ function isLocalBindFailure(stderr) {
493
+ return /address already in use|eaddrinuse|cannot listen|failed to bind|error: bind/i.test(stderr);
494
+ }
495
+ function sshSpawnEnv(env) {
496
+ const out = { ...process.env, ...env };
497
+ delete out.ZSWARM_SERVE_TOKEN;
498
+ return out;
499
+ }
500
+ function redactText(text, token) {
501
+ const secret = token?.trim();
502
+ if (!secret)
503
+ return text;
504
+ return text.split(secret).join("***");
505
+ }
506
+ function cancelledResult() {
507
+ return { ok: false, error: { code: "cancelled", message: "operation cancelled" } };
508
+ }
509
+ function closedResult() {
510
+ return {
511
+ ok: false,
512
+ error: { code: "cancelled", message: "serve tunnel manager is closed" },
513
+ };
514
+ }
515
+ function timeoutResult(message = "operation timed out") {
516
+ return { ok: false, error: { code: "timeout", message } };
517
+ }
518
+ function isFinalProbeFailure(result) {
519
+ if (result.ok)
520
+ return false;
521
+ const code = result.error.code;
522
+ return (code === "serve_unauthorized" ||
523
+ code === "serve_incompatible" ||
524
+ code === "serve_hello_unsupported" ||
525
+ (code === "serve_protocol" &&
526
+ result.error.details?.delivery === "replied"));
527
+ }
528
+ function isAcquireSuccess(result) {
529
+ return result.ok === true && "handle" in result && "localTarget" in result;
530
+ }
531
+ function isNotSentConnectFailure(result) {
532
+ if (result.ok)
533
+ return false;
534
+ const details = result.error.details;
535
+ if (details?.delivery && details.delivery !== "not_sent")
536
+ return false;
537
+ if (result.error.code === "serve_unreachable")
538
+ return true;
539
+ return result.error.code === "timeout" && details?.phase === "connect";
540
+ }
541
+ function asHello(data) {
542
+ if (!data || typeof data !== "object")
543
+ return undefined;
544
+ const d = data;
545
+ if (typeof d.protocol !== "number" ||
546
+ typeof d.serverId !== "string" ||
547
+ typeof d.hostname !== "string" ||
548
+ typeof d.platform !== "string" ||
549
+ typeof d.version !== "string" ||
550
+ !Array.isArray(d.capabilities)) {
551
+ return undefined;
552
+ }
553
+ return {
554
+ protocol: d.protocol,
555
+ serverId: d.serverId,
556
+ hostname: d.hostname,
557
+ platform: d.platform,
558
+ version: d.version,
559
+ capabilities: d.capabilities.filter((cap) => typeof cap === "string"),
560
+ };
561
+ }
562
+ function mergeSignals(...signals) {
563
+ const ac = new AbortController();
564
+ const abort = () => ac.abort();
565
+ const attached = [];
566
+ const dispose = () => {
567
+ for (const signal of attached)
568
+ signal.removeEventListener("abort", abort);
569
+ attached.length = 0;
570
+ };
571
+ for (const signal of signals) {
572
+ if (!signal)
573
+ continue;
574
+ if (signal.aborted) {
575
+ ac.abort();
576
+ dispose();
577
+ return { signal: ac.signal, dispose: () => undefined };
578
+ }
579
+ signal.addEventListener("abort", abort, { once: true });
580
+ attached.push(signal);
581
+ }
582
+ return { signal: ac.signal, dispose };
583
+ }
584
+ const defaultSpawn = (bin, args, options) => {
585
+ const trimmed = bin.trim() || "ssh";
586
+ if (/\.(mjs|cjs|js)$/i.test(trimmed)) {
587
+ return spawn(process.execPath, [trimmed, ...args], options);
588
+ }
589
+ return spawn(trimmed, args, options);
590
+ };
591
+ function stopChild(child) {
592
+ return new Promise((resolve) => {
593
+ if (!childAlive(child)) {
594
+ resolve();
595
+ return;
596
+ }
597
+ const timer = setTimeout(() => {
598
+ try {
599
+ child.kill("SIGKILL");
600
+ }
601
+ catch {
602
+ /* already gone */
603
+ }
604
+ }, 1_000);
605
+ child.once("exit", () => {
606
+ clearTimeout(timer);
607
+ resolve();
608
+ });
609
+ try {
610
+ child.kill("SIGTERM");
611
+ }
612
+ catch {
613
+ clearTimeout(timer);
614
+ resolve();
615
+ }
616
+ });
617
+ }
618
+ export function stripControllerRouting(args) {
619
+ const request = { ...args };
620
+ for (const field of CONTROLLER_ROUTING_FIELDS)
621
+ delete request[field];
622
+ return request;
623
+ }
624
+ export function createServeTunnelManager(options = {}) {
625
+ const persistIdle = options.persistIdle === true;
626
+ const spawnSsh = options.spawnSsh ?? defaultSpawn;
627
+ const allocatePort = options.allocatePort ?? allocateLoopbackPort;
628
+ const probe = options.probe ?? probeServe;
629
+ const now = options.now ?? Date.now;
630
+ const sleep = options.sleep ?? defaultSleep;
631
+ const adoptable = new Map();
632
+ const owned = new Set();
633
+ const queues = new Map();
634
+ const running = new Set();
635
+ const pending = new Set();
636
+ const inflight = new Set();
637
+ const closeAbort = new AbortController();
638
+ let closed = false;
639
+ const remaining = (deadline) => Math.max(0, deadline - now());
640
+ const stopEntry = async (entry) => {
641
+ if (entry.stopping) {
642
+ await entry.stopping;
643
+ return;
644
+ }
645
+ entry.retired = true;
646
+ if (adoptable.get(entry.key) === entry)
647
+ adoptable.delete(entry.key);
648
+ entry.stopping = stopChild(entry.child).finally(() => {
649
+ owned.delete(entry);
650
+ });
651
+ await entry.stopping;
652
+ };
653
+ const makeHandle = (entry, hello) => {
654
+ let released = false;
655
+ return {
656
+ localTarget: entry.localTarget,
657
+ identity: entry.identity,
658
+ hello,
659
+ release: async () => {
660
+ if (released)
661
+ return;
662
+ released = true;
663
+ entry.refs = Math.max(0, entry.refs - 1);
664
+ if (entry.refs === 0 && (!persistIdle || entry.retired || closed)) {
665
+ await stopEntry(entry);
666
+ }
667
+ },
668
+ invalidate: async () => {
669
+ if (!released) {
670
+ released = true;
671
+ entry.refs = Math.max(0, entry.refs - 1);
672
+ }
673
+ entry.retired = true;
674
+ if (adoptable.get(entry.key) === entry)
675
+ adoptable.delete(entry.key);
676
+ if (entry.refs === 0)
677
+ await stopEntry(entry);
678
+ },
679
+ };
680
+ };
681
+ const adopt = (entry, hello) => {
682
+ entry.refs += 1;
683
+ entry.hello = hello;
684
+ return {
685
+ ok: true,
686
+ handle: makeHandle(entry, hello),
687
+ localTarget: entry.localTarget,
688
+ hello,
689
+ };
690
+ };
691
+ const spawnOwned = async (target, env, token, deadline, signal) => {
692
+ let lastError = "ssh LocalForward did not start";
693
+ for (let attempt = 0; attempt < SSH_TUNNEL_PORT_RETRIES; attempt++) {
694
+ if (closed)
695
+ throw new ZellijError("cancelled", "serve tunnel manager is closed");
696
+ if (signal?.aborted)
697
+ throw new ZellijError("cancelled", "operation cancelled");
698
+ if (remaining(deadline) <= 0) {
699
+ throw new ZellijError("timeout", `ssh LocalForward timed out (${formatSshServeTarget(target)})`);
700
+ }
701
+ const localPort = await allocatePort();
702
+ if (closed || signal?.aborted || remaining(deadline) <= 0) {
703
+ if (signal?.aborted)
704
+ throw new ZellijError("cancelled", "operation cancelled");
705
+ if (closed)
706
+ throw new ZellijError("cancelled", "serve tunnel manager is closed");
707
+ throw new ZellijError("timeout", `ssh LocalForward timed out (${formatSshServeTarget(target)})`);
708
+ }
709
+ if (await waitForTcp(localPort, 50, signal)) {
710
+ lastError = `loopback port ${localPort} is already in use`;
711
+ continue;
712
+ }
713
+ const argv = buildSshTunnelArgv(target, localPort, env);
714
+ const child = spawnSsh(argv.bin, argv.args, {
715
+ env: sshSpawnEnv(env),
716
+ stdio: ["ignore", "ignore", "pipe"],
717
+ windowsHide: true,
718
+ detached: false,
719
+ });
720
+ const entry = {
721
+ key: serveTunnelCacheKey(target, env),
722
+ identity: formatSshServeTarget(target),
723
+ child,
724
+ localPort,
725
+ localTarget: formatListenLabel(SSH_SERVE_REMOTE_HOST, localPort),
726
+ refs: 0,
727
+ stderr: "",
728
+ };
729
+ owned.add(entry);
730
+ if (closed || signal?.aborted || remaining(deadline) <= 0) {
731
+ await stopEntry(entry);
732
+ if (closed)
733
+ throw new ZellijError("cancelled", "serve tunnel manager is closed");
734
+ if (signal?.aborted)
735
+ throw new ZellijError("cancelled", "operation cancelled");
736
+ throw new ZellijError("timeout", `ssh LocalForward timed out (${formatSshServeTarget(target)})`);
737
+ }
738
+ child.stderr?.setEncoding("utf8");
739
+ child.stderr?.on("data", (chunk) => {
740
+ entry.stderr = redactText(`${entry.stderr}${chunk}`, token).slice(-SSH_STDERR_CAP);
741
+ });
742
+ child.on("error", (err) => {
743
+ const code = err.code;
744
+ entry.stderr = redactText(`${entry.stderr}${code ? `${code}: ` : ""}${err.message} (bin=${argv.bin})`, token).slice(-SSH_STDERR_CAP);
745
+ try {
746
+ child.kill("SIGTERM");
747
+ }
748
+ catch {
749
+ /* spawn never started */
750
+ }
751
+ });
752
+ child.on("exit", () => {
753
+ owned.delete(entry);
754
+ if (adoptable.get(entry.key) === entry)
755
+ adoptable.delete(entry.key);
756
+ });
757
+ const started = now();
758
+ while (childAlive(child) && remaining(deadline) > 0 && !signal?.aborted && !closed) {
759
+ const ok = await waitForTcp(localPort, Math.min(TCP_PROBE_MS, remaining(deadline)), signal);
760
+ if (ok) {
761
+ await sleep(Math.min(30, remaining(deadline) || 30), signal).catch(() => undefined);
762
+ if (childAlive(child) && !closed && !signal?.aborted)
763
+ return entry;
764
+ break;
765
+ }
766
+ await sleep(Math.min(SPAWN_RETRY_MS, remaining(deadline) || SPAWN_RETRY_MS), signal).catch(() => undefined);
767
+ }
768
+ const stderr = entry.stderr.trim();
769
+ lastError =
770
+ redactText(stderr, token) ||
771
+ (child.exitCode == null
772
+ ? `ssh LocalForward to ${entry.identity} did not become reachable`
773
+ : `ssh LocalForward to ${entry.identity} exited (${child.exitCode})`);
774
+ await stopEntry(entry);
775
+ if (closed)
776
+ throw new ZellijError("cancelled", "serve tunnel manager is closed");
777
+ if (signal?.aborted)
778
+ throw new ZellijError("cancelled", "operation cancelled");
779
+ if (!isLocalBindFailure(stderr) && child.exitCode !== 0 && now() - started < 400) {
780
+ if (stderr && !isLocalBindFailure(stderr)) {
781
+ throw new ZellijError("serve_unreachable", `cannot start ssh LocalForward to ${entry.identity}: ${lastError}`);
782
+ }
783
+ }
784
+ }
785
+ throw new ZellijError("serve_unreachable", `cannot start ssh LocalForward (${redactText(lastError, token)})`);
786
+ };
787
+ const probeReady = async (entry, token, deadline, signal) => {
788
+ let last;
789
+ while (childAlive(entry.child) && remaining(deadline) > 0 && !signal?.aborted && !closed) {
790
+ const budget = Math.min(2_000, remaining(deadline));
791
+ if (budget <= 0)
792
+ break;
793
+ const result = await probe(entry.localTarget, {
794
+ token,
795
+ timeoutMs: budget,
796
+ signal,
797
+ });
798
+ last = result;
799
+ if (result.ok)
800
+ return result;
801
+ if (isFinalProbeFailure(result))
802
+ return result;
803
+ await sleep(Math.min(SPAWN_RETRY_MS, remaining(deadline) || SPAWN_RETRY_MS), signal).catch(() => undefined);
804
+ }
805
+ if (closed)
806
+ return closedResult();
807
+ if (signal?.aborted)
808
+ return cancelledResult();
809
+ if (!childAlive(entry.child)) {
810
+ return {
811
+ ok: false,
812
+ error: {
813
+ code: "serve_unreachable",
814
+ message: `ssh LocalForward to ${entry.identity} closed before serve hello`,
815
+ },
816
+ };
817
+ }
818
+ return (last ?? {
819
+ ok: false,
820
+ error: {
821
+ code: "timeout",
822
+ message: `ssh LocalForward to ${entry.identity} timed out waiting for serve hello`,
823
+ },
824
+ });
825
+ };
826
+ const liveAdoptable = (key) => {
827
+ const existing = adoptable.get(key);
828
+ if (!existing || !childAlive(existing.child) || existing.stopping || existing.retired) {
829
+ return undefined;
830
+ }
831
+ return existing;
832
+ };
833
+ const acquireOne = async (target, env, token, deadline, signal) => {
834
+ const key = serveTunnelCacheKey(target, env);
835
+ const failIfStale = () => {
836
+ if (closed)
837
+ return closedResult();
838
+ if (signal.aborted)
839
+ return cancelledResult();
840
+ if (remaining(deadline) <= 0) {
841
+ return timeoutResult(`ssh LocalForward timed out (${formatSshServeTarget(target)})`);
842
+ }
843
+ return undefined;
844
+ };
845
+ const stale = failIfStale();
846
+ if (stale)
847
+ return stale;
848
+ const existing = liveAdoptable(key);
849
+ if (existing) {
850
+ const probed = await probeReady(existing, token, deadline, signal);
851
+ const afterProbe = failIfStale();
852
+ if (afterProbe)
853
+ return afterProbe;
854
+ if (!probed.ok)
855
+ return probed;
856
+ const hello = asHello(probed.data);
857
+ if (!hello) {
858
+ return {
859
+ ok: false,
860
+ error: {
861
+ code: "serve_protocol",
862
+ message: `ssh LocalForward to ${existing.identity} returned an invalid hello`,
863
+ },
864
+ };
865
+ }
866
+ const beforeAdopt = failIfStale();
867
+ if (beforeAdopt)
868
+ return beforeAdopt;
869
+ if (!liveAdoptable(key) || liveAdoptable(key) !== existing) {
870
+ return (failIfStale() ?? {
871
+ ok: false,
872
+ error: {
873
+ code: "serve_unreachable",
874
+ message: `ssh LocalForward to ${existing.identity} closed before serve hello`,
875
+ },
876
+ });
877
+ }
878
+ return adopt(existing, hello);
879
+ }
880
+ const previous = adoptable.get(key);
881
+ if (previous)
882
+ await stopEntry(previous);
883
+ const started = failIfStale();
884
+ if (started)
885
+ return started;
886
+ const entry = await spawnOwned(target, env, token, deadline, signal);
887
+ try {
888
+ const afterSpawn = failIfStale();
889
+ if (afterSpawn) {
890
+ await stopEntry(entry);
891
+ return afterSpawn;
892
+ }
893
+ const probed = await probeReady(entry, token, deadline, signal);
894
+ const afterProbe = failIfStale();
895
+ if (afterProbe) {
896
+ await stopEntry(entry);
897
+ return afterProbe;
898
+ }
899
+ if (!probed.ok) {
900
+ await stopEntry(entry);
901
+ return probed;
902
+ }
903
+ const hello = asHello(probed.data);
904
+ if (!hello) {
905
+ await stopEntry(entry);
906
+ return {
907
+ ok: false,
908
+ error: {
909
+ code: "serve_protocol",
910
+ message: `ssh LocalForward to ${entry.identity} returned an invalid hello`,
911
+ },
912
+ };
913
+ }
914
+ if (closed || signal.aborted || remaining(deadline) <= 0) {
915
+ await stopEntry(entry);
916
+ return failIfStale() ?? closedResult();
917
+ }
918
+ adoptable.set(key, entry);
919
+ return adopt(entry, hello);
920
+ }
921
+ catch (err) {
922
+ await stopEntry(entry);
923
+ throw err;
924
+ }
925
+ };
926
+ const pruneQueue = (key) => {
927
+ const remainingWaiters = (queues.get(key) ?? []).filter((waiter) => !waiter.settled);
928
+ if (remainingWaiters.length === 0)
929
+ queues.delete(key);
930
+ else
931
+ queues.set(key, remainingWaiters);
932
+ };
933
+ const detachWaiter = (waiter) => {
934
+ if (waiter.timer) {
935
+ clearTimeout(waiter.timer);
936
+ waiter.timer = undefined;
937
+ }
938
+ waiter.dispose?.();
939
+ waiter.dispose = undefined;
940
+ };
941
+ const publishWaiter = (waiter, result) => {
942
+ if (waiter.settled) {
943
+ if (isAcquireSuccess(result))
944
+ void result.handle.release();
945
+ return false;
946
+ }
947
+ waiter.settled = true;
948
+ if (waiter.timer) {
949
+ clearTimeout(waiter.timer);
950
+ waiter.timer = undefined;
951
+ }
952
+ waiter.resolve(result);
953
+ return true;
954
+ };
955
+ const settleWaiter = (waiter, result) => {
956
+ if (!publishWaiter(waiter, result))
957
+ return;
958
+ detachWaiter(waiter);
959
+ };
960
+ const pump = (key) => {
961
+ if (running.has(key))
962
+ return;
963
+ const queue = queues.get(key) ?? [];
964
+ const next = queue.find((waiter) => !waiter.settled);
965
+ if (!next) {
966
+ pruneQueue(key);
967
+ return;
968
+ }
969
+ running.add(key);
970
+ if (next.timer) {
971
+ clearTimeout(next.timer);
972
+ next.timer = undefined;
973
+ }
974
+ const job = (async () => {
975
+ try {
976
+ if (next.settled)
977
+ return;
978
+ if (closed) {
979
+ settleWaiter(next, closedResult());
980
+ return;
981
+ }
982
+ if (next.signal.aborted) {
983
+ settleWaiter(next, cancelledResult());
984
+ return;
985
+ }
986
+ if (remaining(next.deadline) <= 0) {
987
+ settleWaiter(next, timeoutResult("ssh LocalForward timed out waiting for an owned tunnel"));
988
+ return;
989
+ }
990
+ const result = await next.run();
991
+ if (next.settled) {
992
+ if (isAcquireSuccess(result))
993
+ await result.handle.release();
994
+ return;
995
+ }
996
+ settleWaiter(next, result);
997
+ }
998
+ catch (err) {
999
+ settleWaiter(next, fail(err));
1000
+ }
1001
+ finally {
1002
+ running.delete(key);
1003
+ pruneQueue(key);
1004
+ pump(key);
1005
+ }
1006
+ })();
1007
+ inflight.add(job);
1008
+ void job.finally(() => inflight.delete(job));
1009
+ };
1010
+ const enqueue = (key, waiter, deadlineAbort) => {
1011
+ const promise = new Promise((resolve) => {
1012
+ waiter.resolve = resolve;
1013
+ });
1014
+ const queue = queues.get(key) ?? [];
1015
+ queue.push(waiter);
1016
+ queues.set(key, queue);
1017
+ const finishEarly = (result) => {
1018
+ // Publish the public result first so a forwarded abort cannot overwrite
1019
+ // timeout with cancelled. Abort in-flight work while merge listeners are
1020
+ // still attached, then detach so caller signals do not retain waiters.
1021
+ if (!publishWaiter(waiter, result))
1022
+ return;
1023
+ deadlineAbort.abort();
1024
+ detachWaiter(waiter);
1025
+ pruneQueue(key);
1026
+ if (!running.has(key))
1027
+ pump(key);
1028
+ };
1029
+ const onCallerAbort = () => {
1030
+ if (waiter.settled)
1031
+ return;
1032
+ finishEarly(closed ? closedResult() : cancelledResult());
1033
+ };
1034
+ const onTimeout = () => {
1035
+ if (waiter.settled)
1036
+ return;
1037
+ finishEarly(closed
1038
+ ? closedResult()
1039
+ : timeoutResult("ssh LocalForward timed out waiting for an owned tunnel"));
1040
+ };
1041
+ if (closed) {
1042
+ finishEarly(closedResult());
1043
+ return promise;
1044
+ }
1045
+ if (remaining(waiter.deadline) <= 0) {
1046
+ finishEarly(timeoutResult("ssh LocalForward timed out waiting for an owned tunnel"));
1047
+ return promise;
1048
+ }
1049
+ if (waiter.signal.aborted) {
1050
+ finishEarly(cancelledResult());
1051
+ return promise;
1052
+ }
1053
+ waiter.signal.addEventListener("abort", onCallerAbort, { once: true });
1054
+ const previousDispose = waiter.dispose;
1055
+ waiter.dispose = () => {
1056
+ waiter.signal.removeEventListener("abort", onCallerAbort);
1057
+ previousDispose?.();
1058
+ };
1059
+ waiter.timer = setTimeout(onTimeout, Math.max(1, remaining(waiter.deadline)));
1060
+ pump(key);
1061
+ return promise;
1062
+ };
1063
+ const acquire = (raw, opts) => {
1064
+ const env = opts.env ?? {};
1065
+ const target = parseSshServeTarget(raw);
1066
+ const key = serveTunnelCacheKey(target, env);
1067
+ const deadline = now() + Math.max(1, opts.timeoutMs);
1068
+ const deadlineAbort = new AbortController();
1069
+ const merged = mergeSignals(opts.signal, closeAbort.signal, deadlineAbort.signal);
1070
+ const work = () => acquireOne(target, env, opts.token, deadline, merged.signal);
1071
+ if (closed) {
1072
+ merged.dispose();
1073
+ return Promise.resolve(closedResult());
1074
+ }
1075
+ const waiter = {
1076
+ signal: merged.signal,
1077
+ deadline,
1078
+ run: work,
1079
+ resolve: () => undefined,
1080
+ settled: false,
1081
+ dispose: merged.dispose,
1082
+ };
1083
+ const tracked = enqueue(key, waiter, deadlineAbort);
1084
+ pending.add(tracked);
1085
+ void tracked.finally(() => pending.delete(tracked));
1086
+ return tracked;
1087
+ };
1088
+ return {
1089
+ acquire,
1090
+ closeAll: async () => {
1091
+ if (!closed) {
1092
+ closed = true;
1093
+ closeAbort.abort();
1094
+ }
1095
+ for (const queue of queues.values()) {
1096
+ for (const waiter of queue) {
1097
+ settleWaiter(waiter, closedResult());
1098
+ }
1099
+ }
1100
+ queues.clear();
1101
+ const entries = [...owned];
1102
+ adoptable.clear();
1103
+ await Promise.all(entries.map((entry) => stopEntry(entry)));
1104
+ await Promise.allSettled([...inflight, ...pending]);
1105
+ },
1106
+ ownedCount: () => {
1107
+ let n = 0;
1108
+ for (const entry of owned) {
1109
+ if (childAlive(entry.child) && !entry.stopping)
1110
+ n += 1;
1111
+ }
1112
+ return n;
1113
+ },
1114
+ };
1115
+ }
1116
+ export async function forwardServe(input) {
1117
+ const target = input.target.trim();
1118
+ const timeoutMs = Math.max(1, input.timeoutMs);
1119
+ const request = stripControllerRouting(input.args);
1120
+ if (!isSshServeTarget(target)) {
1121
+ return callServe(target, request, timeoutMs, input.token, input.signal);
1122
+ }
1123
+ const owned = input.manager;
1124
+ const manager = owned ?? createServeTunnelManager({ persistIdle: false, now: input.now });
1125
+ const clock = input.now ?? Date.now;
1126
+ const deadline = clock() + timeoutMs;
1127
+ const remaining = () => Math.max(0, deadline - clock());
1128
+ const acquireOpts = () => ({
1129
+ env: input.env,
1130
+ token: input.token,
1131
+ timeoutMs: remaining(),
1132
+ signal: input.signal,
1133
+ });
1134
+ try {
1135
+ if (input.signal?.aborted)
1136
+ return cancelledResult();
1137
+ if (remaining() <= 0)
1138
+ return timeoutResult();
1139
+ const first = await manager.acquire(target, acquireOpts());
1140
+ if (!isAcquireSuccess(first))
1141
+ return first;
1142
+ try {
1143
+ const budget = remaining();
1144
+ if (input.signal?.aborted)
1145
+ return cancelledResult();
1146
+ if (budget <= 0)
1147
+ return timeoutResult();
1148
+ let result = await callServe(first.localTarget, request, budget, input.token, input.signal);
1149
+ if (isNotSentConnectFailure(result) && remaining() > 0 && !input.signal?.aborted) {
1150
+ await first.handle.invalidate();
1151
+ if (remaining() <= 0 || input.signal?.aborted) {
1152
+ return input.signal?.aborted ? cancelledResult() : timeoutResult();
1153
+ }
1154
+ const again = await manager.acquire(target, acquireOpts());
1155
+ if (!isAcquireSuccess(again))
1156
+ return again;
1157
+ try {
1158
+ const retryBudget = remaining();
1159
+ if (input.signal?.aborted)
1160
+ return cancelledResult();
1161
+ if (retryBudget <= 0)
1162
+ return timeoutResult();
1163
+ result = await callServe(again.localTarget, request, retryBudget, input.token, input.signal);
1164
+ }
1165
+ finally {
1166
+ await again.handle.release();
1167
+ }
1168
+ }
1169
+ return result;
1170
+ }
1171
+ finally {
1172
+ await first.handle.release();
1173
+ }
1174
+ }
1175
+ finally {
1176
+ if (!owned)
1177
+ await manager.closeAll();
1178
+ }
1179
+ }