1import type {
2 DesktopSshEnvironmentBootstrap,
3 DesktopSshEnvironmentTarget,
4} from "@t3tools/contracts";
5import {
6 describeReadinessCause,
7 waitForHttpReady as waitForHttpReadyShared,
8} from "@t3tools/shared/httpReadiness";
9import { cliReleaseDownloadBaseUrl } from "@t3tools/shared/cliRelease";
10import * as KeyedLock from "@t3tools/shared/KeyedLock";
11import * as NetService from "@t3tools/shared/Net";
12import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson";
13import { satisfiesSemverRange } from "@t3tools/shared/semver";
14import * as Context from "effect/Context";
15import * as Crypto from "effect/Crypto";
16import * as Effect from "effect/Effect";
17import * as Exit from "effect/Exit";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Path from "effect/Path";
21import * as Schema from "effect/Schema";
22import * as Scope from "effect/Scope";
23import * as Stream from "effect/Stream";
24import { HttpClient } from "effect/http";
25import { ChildProcess, ChildProcessSpawner } from "effect/process";
26
27import * as SshAuth from "./auth.ts";
28import {
29 baseSshArgs,
30 buildSshHostSpecEffect,
31 collectProcessOutput,
32 getLastNonEmptyOutputLine,
33 remoteStateKey,
34 resolveSshCommand,
35 resolveSshTarget,
36 runSshCommand,
37 targetConnectionKey,
38} from "./command.ts";
39import {
40 SshCommandError,
41 SshHttpBridgeError,
42 SshInvalidTargetError,
43 SshLaunchError,
44 SshPairingError,
45 SshPasswordPromptError,
46 SshReadinessError,
47} from "./errors.ts";
48
49const DEFAULT_REMOTE_PORT = 3773;
50const REMOTE_PORT_SCAN_WINDOW = 200;
51const SSH_READY_TIMEOUT_MS = 20_000;
52const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
53const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
54const REMOTE_READY_TIMEOUT_MS = 60_000;
55const REMOTE_LAUNCH_TIMEOUT_MS = 90_000;
56// A cold archive launch also downloads and unpacks a ~70 MB release archive
57// and may wait on another installer's lock. The budgets nest: the checksum
58// file is tiny and the archive download is bounded; a waiter outlasts both
59// downloads plus extraction so it can reuse the result; and the SSH command
60// outlasts an install (own or waited-for) plus readiness, with slack for
61// verification and extraction, which have no timeout of their own.
62const REMOTE_ARCHIVE_CHECKSUMS_SECONDS = 30;
63const REMOTE_ARCHIVE_DOWNLOAD_SECONDS = 240;
64const REMOTE_ARCHIVE_LOCK_WAIT_SECONDS = 360;
65const REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS = 900_000;
66const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000;
67
68export interface RemoteT3RunnerOptions {
69 /**
70 * Dev mode: run `node <path>` on the remote instead of a release archive.
71 * The only mode that needs Node on the remote.
72 */
73 readonly nodeScriptPath?: string | null;
74 readonly nodeEngineRange?: string | null;
75 /**
76 * Exact version whose self-contained release archive the remote installs
77 * and runs. Required unless `nodeScriptPath` is set; the remote then needs
78 * neither Node nor npm.
79 */
80 readonly archiveVersion?: string | null;
81 readonly releaseBaseUrl?: string | null;
82}
83
84export interface SshEnvironmentManagerOptions {
85 readonly resolveCliRunner?: Effect.Effect<RemoteT3RunnerOptions>;
86}
87
88interface SshTunnelEntry {
89 readonly key: string;
90 readonly target: DesktopSshEnvironmentTarget;
91 readonly remotePort: number;
92 readonly remoteServerKind: "external" | "managed" | null;
93 readonly localPort: number;
94 readonly httpBaseUrl: string;
95 readonly wsBaseUrl: string;
96 readonly process: ChildProcessSpawner.ChildProcessHandle;
97 readonly scope: Scope.Closeable;
98}
99
100type SshEnvironmentEffectContext =
101 | ChildProcessSpawner.ChildProcessSpawner
102 | Crypto.Crypto
103 | FileSystem.FileSystem
104 | Path.Path
105 | HttpClient.HttpClient
106 | NetService.NetService
107 | SshAuth.SshPasswordPrompt;
108
109type SshEnvironmentEffectError =
110 | SshCommandError
111 | SshInvalidTargetError
112 | SshLaunchError
113 | SshPairingError
114 | SshReadinessError
115 | SshPasswordPromptError
116 | NetService.NetError;
117
118function sshTargetLogFields(target: DesktopSshEnvironmentTarget) {
119 return {
120 alias: target.alias,
121 hostname: target.hostname,
122 username: target.username,
123 port: target.port,
124 };
125}
126
127function isNodeScriptRunner(runner: RemoteT3RunnerOptions | undefined): boolean {
128 return Boolean(runner?.nodeScriptPath?.trim());
129}
130
131function sshRunnerLogFields(runner: RemoteT3RunnerOptions | undefined) {
132 if (runner?.nodeScriptPath?.trim()) {
133 return { runner: "node-script", nodeScriptPath: runner.nodeScriptPath.trim() };
134 }
135 if (runner?.archiveVersion?.trim()) {
136 return { runner: "archive", archiveVersion: runner.archiveVersion.trim() };
137 }
138 return { runner: "archive" };
139}
140
141interface SshAuthOperationInput<T> {
142 readonly key: string;
143 readonly target: DesktopSshEnvironmentTarget;
144 readonly operation: (
145 authOptions: SshAuth.SshAuthOptions,
146 ) => Effect.Effect<T, SshEnvironmentEffectError, SshEnvironmentEffectContext>;
147}
148
149interface SshAuthAttemptInput<T> extends SshAuthOperationInput<T> {
150 readonly promptCount: number;
151 readonly authSecret: string | null;
152}
153
154export interface SshEnvironmentManagerShape {
155 readonly ensureEnvironment: (
156 target: DesktopSshEnvironmentTarget,
157 options?: { readonly issuePairingToken?: boolean },
158 ) => Effect.Effect<
159 DesktopSshEnvironmentBootstrap,
160 SshEnvironmentEffectError,
161 SshEnvironmentEffectContext
162 >;
163 readonly disconnectEnvironment: (
164 target: DesktopSshEnvironmentTarget,
165 ) => Effect.Effect<void, SshEnvironmentEffectError, SshEnvironmentEffectContext>;
166}
167
168const RemoteLaunchResult = Schema.Struct({
169 remotePort: Schema.Number,
170 serverKind: Schema.optional(Schema.Literals(["external", "managed"])),
171});
172
173const RemotePairingResult = Schema.Struct({
174 credential: Schema.String,
175});
176
177const decodeRemoteLaunchResult = Schema.decodeEffect(fromLenientJson(RemoteLaunchResult));
178const decodeRemotePairingResult = Schema.decodeEffect(fromLenientJson(RemotePairingResult));
179
180const decodeRemoteJsonOutput = <A, E>(
181 stdout: string,
182 decode: (input: string) => Effect.Effect<A, E>,
183): Effect.Effect<A, E> =>
184 decode(stdout).pipe(
185 Effect.catch((error) =>
186 Effect.gen(function* () {
187 const jsonObject = extractJsonObject(stdout);
188 if (jsonObject === stdout.trim()) {
189 return yield* Effect.fail(error);
190 }
191 const exit = yield* Effect.exit(decode(jsonObject));
192 if (Exit.isSuccess(exit)) {
193 return exit.value;
194 }
195 return yield* Effect.fail(error);
196 }),
197 ),
198 );
199
200const decodeRemoteLaunchOutput = (stdout: string) =>
201 decodeRemoteJsonOutput(stdout, decodeRemoteLaunchResult);
202
203const decodeRemotePairingOutput = (stdout: string) =>
204 decodeRemoteJsonOutput(stdout, decodeRemotePairingResult);
205
206const remoteNodeEngineCheckMain = function remoteNodeEngineCheckMain() {
207 const range = process.argv[2] || "";
208 const rawVersion =
209 process.versions && process.versions.node ? process.versions.node : process.version;
210
211 if (!satisfiesSemverRange(rawVersion, range)) {
212 process.stderr.write(
213 "Remote node " + rawVersion + " does not satisfy required range " + range + ".\n",
214 );
215 process.exit(1);
216 }
217};
218
219function buildRemoteNodeEngineCheckScript(): string {
220 return `${satisfiesSemverRange.toString()}
221(${remoteNodeEngineCheckMain.toString()})();`;
222}
223
224function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string {
225 const cleaned = stderr.trim();
226 return cleaned.length > 0 ? cleaned : fallbackMessage;
227}
228
229function stripTrailingNewlines(value: string): string {
230 return value.replace(/\n+$/u, "");
231}
232
233function shellSingleQuote(value: string): string {
234 return `'${value.replaceAll("'", "'\\''")}'`;
235}
236
237function applyScriptPlaceholders(
238 template: string,
239 replacements: Readonly<Record<string, string>>,
240): string {
241 let result = template;
242 for (const [token, value] of Object.entries(replacements)) {
243 // A replacer function keeps `$$` and other `$` patterns in the value literal.
244 result = result.replaceAll(`@@${token}@@`, () => value);
245 }
246 return result;
247}
248
249// Re-exported from the shared HTTP readiness module so existing importers
250// (notably tunnel.test.ts) keep resolving it from here.
251export { describeReadinessCause };
252
253export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs");
254const net = require("node:net");
255const filePath = process.argv[2] ?? "";
256const defaultPort = Number.parseInt(process.argv[3] ?? "", 10);
257const scanWindow = Number.parseInt(process.argv[4] ?? "", 10);
258const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : "";
259const preferred = Number.parseInt(raw, 10);
260const start = Number.isInteger(preferred) ? preferred : defaultPort;
261const end = start + scanWindow;
262
263function canBind(port) {
264 return new Promise((resolve) => {
265 const server = net.createServer();
266 server.unref();
267 server.once("error", () => resolve(false));
268 server.listen(port, "127.0.0.1", () => {
269 server.close((error) => resolve(!error));
270 });
271 });
272}
273
274// macOS lets the bind above succeed while another process listens on
275// *:<port>, so also skip any port that already answers on loopback.
276function answersOnLoopback(port) {
277 return new Promise((resolve) => {
278 const socket = net.connect({ port, host: "127.0.0.1" });
279 socket.setTimeout(1000);
280 socket.once("connect", () => {
281 socket.destroy();
282 resolve(true);
283 });
284 socket.once("timeout", () => {
285 socket.destroy();
286 resolve(true);
287 });
288 socket.once("error", () => resolve(false));
289 });
290}
291
292(async () => {
293 for (let port = start; port < end; port += 1) {
294 const available = (await canBind(port)) && !(await answersOnLoopback(port));
295 if (available) {
296 process.stdout.write(String(port));
297 return;
298 }
299 }
300 process.exit(1);
301})().catch(() => process.exit(1));
302`;
303
304const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http");
305const port = Number.parseInt(process.argv[2] ?? "", 10);
306const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10);
307const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10);
308if (!Number.isInteger(port) || !Number.isInteger(timeoutMs) || !Number.isInteger(probeTimeoutMs)) {
309 process.exit(1);
310}
311const deadline = Date.now() + timeoutMs;
312
313function sleep(ms) {
314 return new Promise((resolve) => setTimeout(resolve, ms));
315}
316
317function probe() {
318 return new Promise((resolve) => {
319 const request = http.get(
320 {
321 hostname: "127.0.0.1",
322 port,
323 path: "/",
324 timeout: probeTimeoutMs,
325 },
326 (response) => {
327 response.resume();
328 response.once("end", () => {
329 resolve(response.statusCode >= 200 && response.statusCode < 300);
330 });
331 },
332 );
333 request.once("timeout", () => {
334 request.destroy();
335 resolve(false);
336 });
337 request.once("error", () => resolve(false));
338 });
339}
340
341(async () => {
342 while (Date.now() < deadline) {
343 if (await probe()) {
344 process.exit(0);
345 }
346 await sleep(100);
347 }
348 process.exit(1);
349})().catch(() => process.exit(1));
350`;
351
352const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() {
353 if [ -d "$1" ]; then
354 case ":$PATH:" in
355 *":$1:"*) ;;
356 *) PATH="$1:$PATH" ;;
357 esac
358 fi
359}
360
361remote_node_satisfies_engine() {
362 T3_NODE_ENGINE_RANGE=@@T3_NODE_ENGINE_RANGE@@
363 if [ -z "$T3_NODE_ENGINE_RANGE" ]; then
364 return 0
365 fi
366 node - "$T3_NODE_ENGINE_RANGE" <<'NODE'
367@@T3_NODE_ENGINE_CHECK_SCRIPT@@
368NODE
369}
370
371ensure_remote_node_path() {
372 if command -v node >/dev/null 2>&1 && remote_node_satisfies_engine >/dev/null 2>&1; then
373 return 0
374 fi
375
376 prepend_path_if_dir "$HOME/.local/bin"
377 prepend_path_if_dir "$HOME/bin"
378 prepend_path_if_dir "/opt/homebrew/bin"
379 prepend_path_if_dir "/home/linuxbrew/.linuxbrew/bin"
380 prepend_path_if_dir "/usr/local/bin"
381 prepend_path_if_dir "/usr/bin"
382 prepend_path_if_dir "/bin"
383
384 if [ -z "\${VOLTA_HOME:-}" ]; then
385 VOLTA_HOME="$HOME/.volta"
386 fi
387 export VOLTA_HOME
388 prepend_path_if_dir "$VOLTA_HOME/bin"
389
390 prepend_path_if_dir "$HOME/.asdf/shims"
391 prepend_path_if_dir "$HOME/.asdf/bin"
392 if [ ! -x "$HOME/.asdf/shims/node" ] && [ -s "$HOME/.asdf/asdf.sh" ]; then
393 # shellcheck disable=SC1090
394 . "$HOME/.asdf/asdf.sh"
395 fi
396
397 prepend_path_if_dir "$HOME/.local/share/mise/shims"
398 prepend_path_if_dir "$HOME/.mise/shims"
399 if ! command -v node >/dev/null 2>&1 && command -v mise >/dev/null 2>&1; then
400 eval "$(mise activate sh)" >/dev/null 2>&1 || true
401 fi
402
403 if [ -z "\${FNM_DIR:-}" ]; then
404 FNM_DIR="$HOME/.local/share/fnm"
405 fi
406 export FNM_DIR
407 prepend_path_if_dir "$FNM_DIR"
408 prepend_path_if_dir "$HOME/.fnm"
409 if ! command -v node >/dev/null 2>&1 && command -v fnm >/dev/null 2>&1; then
410 eval "$(fnm env --shell bash)" >/dev/null 2>&1 || true
411 fnm use --silent-if-unchanged >/dev/null 2>&1 || fnm use default >/dev/null 2>&1 || true
412 fi
413
414 prepend_path_if_dir "$HOME/.nodenv/bin"
415 prepend_path_if_dir "$HOME/.nodenv/shims"
416 if ! command -v node >/dev/null 2>&1 && command -v nodenv >/dev/null 2>&1; then
417 eval "$(nodenv init -)" >/dev/null 2>&1 || true
418 fi
419
420 if [ -z "\${NVM_DIR:-}" ]; then
421 NVM_DIR="$HOME/.nvm"
422 fi
423 export NVM_DIR
424
425 if [ -s "$NVM_DIR/nvm.sh" ]; then
426 # shellcheck disable=SC1090
427 . "$NVM_DIR/nvm.sh"
428 if ! command -v node >/dev/null 2>&1 && command -v nvm >/dev/null 2>&1; then
429 nvm use --silent default >/dev/null 2>&1 || nvm use --silent node >/dev/null 2>&1 || nvm use --silent --lts >/dev/null 2>&1 || true
430 fi
431 fi
432
433 if ! command -v node >/dev/null 2>&1 && [ -d "$NVM_DIR/versions/node" ]; then
434 for T3_NODE_BIN in "$NVM_DIR"/versions/node/*/bin; do
435 if [ -x "$T3_NODE_BIN/node" ]; then
436 PATH="$T3_NODE_BIN:$PATH"
437 export PATH
438 fi
439 done
440 fi
441
442 command -v node >/dev/null 2>&1 && remote_node_satisfies_engine
443}
444`;
445
446const REMOTE_RUNNER_SCRIPT = `#!/bin/sh
447set -eu
448@@T3_NODE_ENV_SCRIPT@@
449T3_NODE_SCRIPT_PATH=@@T3_NODE_SCRIPT_PATH@@
450if [ -n "$T3_NODE_SCRIPT_PATH" ]; then
451 # Dev mode: a source checkout on the remote. This is the only path that
452 # needs Node, so Node discovery runs here and nowhere else.
453 ensure_remote_node_path || true
454 if ! command -v node >/dev/null 2>&1; then
455 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
456 exit 1
457 fi
458 exec node "$T3_NODE_SCRIPT_PATH" "$@"
459fi
460T3_ARCHIVE_VERSION=@@T3_ARCHIVE_VERSION@@
461if [ -z "$T3_ARCHIVE_VERSION" ]; then
462 printf 'No t3 release version was provided for the remote runtime.\\n' >&2
463 exit 1
464fi
465# Self-contained release archive: no Node, npm, or compiler on the remote.
466# Unpacked into the pinned-runtime layout so \`t3 service install\` reuses it.
467T3_RELEASE_BASE_URL=@@T3_RELEASE_BASE_URL@@
468T3_RUNTIME_DIR="$HOME/.t3/runtime/versions/$T3_ARCHIVE_VERSION"
469t3_runtime_ready() {
470 [ -x "$T3_RUNTIME_DIR/t3" ] && [ "$(cat "$T3_RUNTIME_DIR/.install-complete" 2>/dev/null)" = "$T3_ARCHIVE_VERSION" ]
471}
472if ! t3_runtime_ready; then
473 mkdir -p "$HOME/.t3/runtime/versions"
474 # Concurrent launches (two clients, a retry racing a slow first run) must
475 # not both install: mkdir is the atomic lock and the ready check repeats
476 # under it.
477 T3_LOCK="$HOME/.t3/runtime/versions/.$T3_ARCHIVE_VERSION.install.lock"
478 # mkdir is the only portable atomic exclusive create (mv would silently
479 # nest a candidate inside an existing lock). The owner publishes its pid
480 # right after, so a lock with a live owner is never reclaimed however
481 # slow its download is, and a lock whose owner is dead is reclaimed at
482 # once. A lock with no pid at all is a crash between mkdir and the pid
483 # write; it is reclaimed after a short grace so a live owner has time to
484 # publish.
485 T3_LOCK_WAITED=0
486 T3_LOCK_UNOWNED=0
487 while ! mkdir "$T3_LOCK" 2>/dev/null; do
488 T3_LOCK_OWNER="$(cat "$T3_LOCK/pid" 2>/dev/null || true)"
489 if [ -n "$T3_LOCK_OWNER" ]; then
490 T3_LOCK_UNOWNED=0
491 if ! kill -0 "$T3_LOCK_OWNER" 2>/dev/null; then
492 rm -rf "$T3_LOCK"
493 continue
494 fi
495 else
496 T3_LOCK_UNOWNED=$((T3_LOCK_UNOWNED + 1))
497 if [ "$T3_LOCK_UNOWNED" -ge 5 ]; then
498 rm -rf "$T3_LOCK"
499 continue
500 fi
501 fi
502 if [ "$T3_LOCK_WAITED" -ge @@T3_ARCHIVE_LOCK_WAIT_SECONDS@@ ]; then
503 printf 'Another t3 %s installation has held %s for too long.\\n' "$T3_ARCHIVE_VERSION" "$T3_LOCK" >&2
504 exit 1
505 fi
506 sleep 1
507 T3_LOCK_WAITED=$((T3_LOCK_WAITED + 1))
508 done
509 printf '%s\\n' "$$" > "$T3_LOCK/pid.tmp" && mv "$T3_LOCK/pid.tmp" "$T3_LOCK/pid"
510 trap 'rm -rf "$T3_LOCK"' EXIT
511fi
512if ! t3_runtime_ready; then
513 case "$(uname -s)" in
514 Darwin) T3_PLATFORM="darwin" ;;
515 Linux) T3_PLATFORM="linux" ;;
516 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -s)" >&2; exit 1 ;;
517 esac
518 case "$(uname -m)" in
519 arm64 | aarch64) T3_ARCH="arm64" ;;
520 x86_64 | amd64) T3_ARCH="x64" ;;
521 *) printf 'Remote host %s has no t3 release archive.\\n' "$(uname -m)" >&2; exit 1 ;;
522 esac
523 T3_ARCHIVE="t3-$T3_ARCHIVE_VERSION-$T3_PLATFORM-$T3_ARCH.tar.gz"
524 T3_STAGING="$(mktemp -d "$HOME/.t3/runtime/versions/.staging-XXXXXX")"
525 trap 'rm -rf "$T3_STAGING" "$T3_LOCK"' EXIT
526 t3_fetch() {
527 if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 30 --max-time "$3" "$1" -o "$2"
528 elif command -v wget >/dev/null 2>&1; then wget -q --timeout=30 --tries=1 "$1" -O "$2"
529 else printf 'Remote host needs curl or wget to download %s.\\n' "$T3_ARCHIVE" >&2; exit 1
530 fi
531 }
532 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/SHA256SUMS" "$T3_STAGING/SHA256SUMS" @@T3_ARCHIVE_CHECKSUMS_SECONDS@@
533 t3_fetch "$T3_RELEASE_BASE_URL/v$T3_ARCHIVE_VERSION/$T3_ARCHIVE" "$T3_STAGING/$T3_ARCHIVE" @@T3_ARCHIVE_DOWNLOAD_SECONDS@@
534 T3_EXPECTED="$(grep " \\*\\{0,1\\}$T3_ARCHIVE$" "$T3_STAGING/SHA256SUMS" | cut -d' ' -f1)"
535 if command -v sha256sum >/dev/null 2>&1; then
536 T3_ACTUAL="$(sha256sum "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
537 else
538 T3_ACTUAL="$(shasum -a 256 "$T3_STAGING/$T3_ARCHIVE" | cut -d' ' -f1)"
539 fi
540 if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then
541 printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1
542 fi
543 tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1
544 rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS"
545 # Prove the binary runs here (libc, arch) before marking it ready, or every
546 # later launch would exec a broken install instead of retrying.
547 if ! "$T3_STAGING/t3" --version >/dev/null 2>&1; then
548 printf 'The t3 %s executable does not run on this host.\\n' "$T3_ARCHIVE_VERSION" >&2; exit 1
549 fi
550 printf '%s\\n' "$T3_ARCHIVE_VERSION" > "$T3_STAGING/.install-complete"
551 rm -rf "$T3_RUNTIME_DIR"
552 mv "$T3_STAGING" "$T3_RUNTIME_DIR"
553fi
554if [ -n "\${T3_LOCK:-}" ]; then
555 rm -rf "$T3_LOCK"
556 trap - EXIT
557fi
558exec "$T3_RUNTIME_DIR/t3" "$@"
559`;
560
561const REMOTE_LAUNCH_SCRIPT = `set -eu
562@@T3_NODE_ENV_SCRIPT@@
563STATE_KEY="$1"
564STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
565DEFAULT_SERVER_HOME="$HOME/.t3"
566DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"
567PORT_FILE="$STATE_DIR/port"
568PID_FILE="$STATE_DIR/pid"
569MANAGED_FILE="$STATE_DIR/managed"
570LOG_FILE="$STATE_DIR/server.log"
571RUNNER_FILE="$STATE_DIR/run-t3.sh"
572RUNNER_NEXT="$STATE_DIR/run-t3.next.$$"
573mkdir -p "$STATE_DIR"
574cleanup_runner_next() {
575 rm -f "$RUNNER_NEXT"
576}
577trap cleanup_runner_next EXIT
578cat >"$RUNNER_NEXT" <<'SH'
579@@T3_RUNNER_SCRIPT@@
580SH
581RUNNER_CHANGED=0
582if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
583 RUNNER_CHANGED=1
584fi
585mv "$RUNNER_NEXT" "$RUNNER_FILE"
586chmod 700 "$RUNNER_FILE"
587T3_ARCHIVE_MODE=@@T3_ARCHIVE_MODE@@
588if [ "$T3_ARCHIVE_MODE" = "1" ]; then
589 # The archive ships the helpers below inside the executable; the remote
590 # needs no Node at all. Resolving the runner once here also downloads the
591 # archive before the port and readiness probes rely on it.
592 "$RUNNER_FILE" --version >/dev/null
593elif ! ensure_remote_node_path; then
594 printf 'Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.\\n' >&2
595 exit 1
596fi
597pick_port() {
598 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
599 "$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@"
600 return
601 fi
602 node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE'
603@@T3_PICK_PORT_SCRIPT@@
604NODE
605}
606wait_ready() {
607 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
608 "$RUNNER_FILE" __ssh-helper wait-ready "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@"
609 return
610 fi
611 node - "$REMOTE_PORT" "$1" "@@T3_READY_PROBE_TIMEOUT_MS@@" <<'NODE'
612@@T3_WAIT_READY_SCRIPT@@
613NODE
614}
615wait_for_pid_exit() {
616 PID_TO_WAIT="$1"
617 WAIT_COUNT=0
618 while kill -0 "$PID_TO_WAIT" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
619 WAIT_COUNT=$((WAIT_COUNT + 1))
620 sleep 0.1
621 done
622}
623# kill -0 also succeeds for a server that exited but was not reaped yet, so
624# a zombie does not count as running. Without ps to tell, trust kill -0.
625server_running() {
626 [ -n "$1" ] && kill -0 "$1" 2>/dev/null || return 1
627 case "$(ps -o stat= -p "$1" 2>/dev/null || true)" in
628 *Z*) return 1 ;;
629 esac
630}
631resolve_default_runtime_port() {
632 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
633 "$RUNNER_FILE" __ssh-helper runtime-port "$DEFAULT_RUNTIME_FILE"
634 return
635 fi
636 node - "$DEFAULT_RUNTIME_FILE" <<'NODE'
637const fs = require("node:fs");
638const runtimePath = process.argv[2] ?? "";
639try {
640 const runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8"));
641 const pid = Number(runtime.pid);
642 const port = Number(runtime.port);
643 if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port)) {
644 process.exit(1);
645 }
646 const origin = new URL(String(runtime.origin ?? ""));
647 if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) {
648 process.exit(1);
649 }
650 process.kill(pid, 0);
651 process.stdout.write(\`\${pid} \${port}\`);
652} catch {
653 process.exit(1);
654}
655NODE
656}
657REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
658REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
659REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
660DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)"
661DEFAULT_RUNTIME_PID=""
662DEFAULT_REMOTE_PORT=""
663ADOPTED_PID=""
664if [ -n "$DEFAULT_RUNTIME_INFO" ]; then
665 DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}"
666 DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }"
667fi
668# The managed server runs on the default home, so it writes the default
669# runtime file too. When that file names the PID this launcher recorded as
670# managed, it describes our own server and its real port, not one to adopt.
671if [ -n "$DEFAULT_REMOTE_PORT" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ "$DEFAULT_RUNTIME_PID" = "$REMOTE_PID" ]; then
672 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
673 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
674elif [ -n "$DEFAULT_REMOTE_PORT" ]; then
675 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
676 if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
677 ADOPTED_PID="$DEFAULT_RUNTIME_PID"
678 if [ "$REMOTE_MANAGED" = "managed" ]; then
679 if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
680 kill "$REMOTE_PID" 2>/dev/null || true
681 wait_for_pid_exit "$REMOTE_PID"
682 fi
683 REMOTE_PID=""
684 REMOTE_PORT="$DEFAULT_REMOTE_PORT"
685 REMOTE_MANAGED="external"
686 rm -f "$PID_FILE"
687 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
688 printf 'external\\n' >"$MANAGED_FILE"
689 else
690 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
691 printf 'external\\n' >"$MANAGED_FILE"
692 REMOTE_PID=""
693 REMOTE_MANAGED="external"
694 fi
695 else
696 REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
697 REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)"
698 REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
699 fi
700fi
701if [ "$REMOTE_MANAGED" = "external" ]; then
702 # A server adopted above has just answered. While it is still alive, probing
703 # it again could catch a momentary stall and launch a second server beside it.
704 if server_running "$ADOPTED_PID"; then
705 :
706 elif [ -z "$REMOTE_PORT" ] || ! wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then
707 REMOTE_PID=""
708 REMOTE_PORT=""
709 REMOTE_MANAGED=""
710 fi
711elif [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && server_running "$REMOTE_PID"; then
712 # Reconnect owns the local tunnel, not the lifetime of an already running
713 # server. A changed runner is the update path and still replaces it.
714 if [ "$RUNNER_CHANGED" -eq 1 ]; then
715 kill "$REMOTE_PID" 2>/dev/null || true
716 wait_for_pid_exit "$REMOTE_PID"
717 REMOTE_PID=""
718 REMOTE_PORT=""
719 REMOTE_MANAGED=""
720 elif ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then
721 printf 'Remote T3 server is still running but did not become ready. Retry the connection; the server was left running.\\n' >&2
722 exit 1
723 fi
724else
725 REMOTE_PID=""
726 REMOTE_PORT=""
727 REMOTE_MANAGED=""
728fi
729if [ -z "$REMOTE_PORT" ]; then
730 REMOTE_PORT="$(pick_port)" || true
731 if [ -z "$REMOTE_PORT" ]; then
732 if [ "$T3_ARCHIVE_MODE" = "1" ]; then
733 printf 'Failed to find an available port on the remote host.\\n' >&2
734 else
735 printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2
736 fi
737 exit 1
738 fi
739 nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null &
740 REMOTE_PID="$!"
741 printf '%s\\n' "$REMOTE_PID" >"$PID_FILE"
742 printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
743 printf 'managed\\n' >"$MANAGED_FILE"
744 if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then
745 printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2
746 if [ -s "$LOG_FILE" ]; then
747 tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true
748 else
749 printf 'It wrote nothing to %s, so it exited before producing any output.\\n' "$LOG_FILE" >&2
750 fi
751 kill "$REMOTE_PID" 2>/dev/null || true
752 wait_for_pid_exit "$REMOTE_PID"
753 rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
754 exit 1
755 fi
756fi
757printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}"
758`;
759
760const REMOTE_PAIRING_SCRIPT = `set -eu
761STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
762DEFAULT_SERVER_HOME="$HOME/.t3"
763RUNNER_FILE="$STATE_DIR/run-t3.sh"
764mkdir -p "$STATE_DIR"
765cat >"$RUNNER_FILE" <<'SH'
766@@T3_RUNNER_SCRIPT@@
767SH
768chmod 700 "$RUNNER_FILE"
769PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"
770"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json
771`;
772
773const REMOTE_STOP_SCRIPT = `set -eu
774STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
775PID_FILE="$STATE_DIR/pid"
776PORT_FILE="$STATE_DIR/port"
777MANAGED_FILE="$STATE_DIR/managed"
778REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)"
779REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)"
780if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then
781 kill "$REMOTE_PID" 2>/dev/null || true
782 WAIT_COUNT=0
783 while kill -0 "$REMOTE_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do
784 WAIT_COUNT=$((WAIT_COUNT + 1))
785 sleep 0.1
786 done
787 if kill -0 "$REMOTE_PID" 2>/dev/null; then
788 printf 'Remote T3 server with PID %s did not stop within 2 seconds. Its ownership files were kept.\\n' "$REMOTE_PID" >&2
789 exit 1
790 fi
791fi
792rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"
793printf '{"stopped":true}\\n'
794`;
795
796const REMOTE_LOG_TAIL_SCRIPT = `set -eu
797STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
798LOG_FILE="$STATE_DIR/server.log"
799if [ -f "$LOG_FILE" ]; then
800 tail -n 80 "$LOG_FILE" 2>/dev/null || true
801fi
802`;
803
804export class SshInvalidArchiveVersionError extends Schema.TaggedError<SshInvalidArchiveVersionError>()(
805 "SshInvalidArchiveVersionError",
806 { archiveVersion: Schema.String },
807) {
808 override get message(): string {
809 return `'${this.archiveVersion}' is not an exact t3 version and cannot name a runtime directory.`;
810 }
811}
812
813// The version becomes a directory name the runner removes and recreates, so
814// it must be one exact SemVer segment: no separators, no `..`, no shell
815// metacharacters beyond what SemVer allows.
816const EXACT_ARCHIVE_VERSION =
817 /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
818
819export class SshMissingRunnerError extends Schema.TaggedError<SshMissingRunnerError>()(
820 "SshMissingRunnerError",
821 {},
822) {
823 override get message(): string {
824 return "A remote t3 runner needs an archive version or a node script path.";
825 }
826}
827
828export function buildRemoteT3RunnerScript(input?: RemoteT3RunnerOptions): string {
829 const nodeScriptPath = input?.nodeScriptPath?.trim() || "";
830 const archiveVersion = input?.archiveVersion?.trim() || "";
831 if (nodeScriptPath === "" && archiveVersion === "") {
832 throw new SshMissingRunnerError();
833 }
834 if (archiveVersion !== "" && !EXACT_ARCHIVE_VERSION.test(archiveVersion)) {
835 throw new SshInvalidArchiveVersionError({ archiveVersion });
836 }
837 // Strip the `/v<version>` the helper appends: the script builds URLs itself.
838 const releaseBaseUrl = cliReleaseDownloadBaseUrl("", input?.releaseBaseUrl ?? undefined).replace(
839 /\/v$/u,
840 "",
841 );
842 return stripTrailingNewlines(
843 applyScriptPlaceholders(REMOTE_RUNNER_SCRIPT, {
844 T3_NODE_SCRIPT_PATH: shellSingleQuote(nodeScriptPath),
845 T3_ARCHIVE_VERSION: shellSingleQuote(archiveVersion),
846 T3_RELEASE_BASE_URL: shellSingleQuote(releaseBaseUrl),
847 T3_ARCHIVE_LOCK_WAIT_SECONDS: String(REMOTE_ARCHIVE_LOCK_WAIT_SECONDS),
848 T3_ARCHIVE_DOWNLOAD_SECONDS: String(REMOTE_ARCHIVE_DOWNLOAD_SECONDS),
849 T3_ARCHIVE_CHECKSUMS_SECONDS: String(REMOTE_ARCHIVE_CHECKSUMS_SECONDS),
850 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
851 }),
852 );
853}
854
855export function buildRemoteNodeEnvScript(input?: RemoteT3RunnerOptions): string {
856 return stripTrailingNewlines(
857 applyScriptPlaceholders(REMOTE_NODE_ENV_SCRIPT, {
858 T3_NODE_ENGINE_RANGE: shellSingleQuote(input?.nodeEngineRange?.trim() || ""),
859 T3_NODE_ENGINE_CHECK_SCRIPT: stripTrailingNewlines(buildRemoteNodeEngineCheckScript()),
860 }),
861 );
862}
863
864export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string {
865 return applyScriptPlaceholders(REMOTE_LAUNCH_SCRIPT, {
866 T3_ARCHIVE_MODE: isNodeScriptRunner(input) ? "0" : "1",
867 T3_NODE_ENV_SCRIPT: buildRemoteNodeEnvScript(input),
868 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
869 T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT),
870 T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT),
871 T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT),
872 T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW),
873 T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS),
874 T3_REUSE_READY_TIMEOUT_MS: String(REMOTE_REUSE_READY_TIMEOUT_MS),
875 T3_READY_PROBE_TIMEOUT_MS: String(SSH_READY_PROBE_TIMEOUT_MS),
876 });
877}
878
879export function buildRemotePairingScript(stateKey: string, input?: RemoteT3RunnerOptions): string {
880 return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, {
881 T3_STATE_KEY: stateKey,
882 T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)),
883 });
884}
885
886export function buildRemoteStopScript(stateKey: string): string {
887 return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, {
888 T3_STATE_KEY: stateKey,
889 });
890}
891
892function buildRemoteLogTailScript(stateKey: string): string {
893 return applyScriptPlaceholders(REMOTE_LOG_TAIL_SCRIPT, {
894 T3_STATE_KEY: stateKey,
895 });
896}
897
898export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemoteServer")(
899 function* (
900 target: DesktopSshEnvironmentTarget,
901 input?: SshAuth.SshAuthOptions,
902 runner?: RemoteT3RunnerOptions,
903 ): Effect.fn.Return<
904 { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },
905 SshCommandError | SshInvalidTargetError | SshLaunchError,
906 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
907 > {
908 const stateKey = yield* remoteStateKey(target);
909 yield* Effect.logInfo("ssh.remoteServer.launch.start", {
910 ...sshTargetLogFields(target),
911 ...sshRunnerLogFields(runner),
912 stateKey,
913 });
914 const result = yield* runSshCommand(target, {
915 remoteCommandArgs: ["sh", "-l", "-s", "--", stateKey],
916 stdin: buildRemoteLaunchScript(runner),
917 timeoutMs: isNodeScriptRunner(runner)
918 ? REMOTE_LAUNCH_TIMEOUT_MS
919 : REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS,
920 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
921 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
922 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
923 });
924 if (!getLastNonEmptyOutputLine(result.stdout)) {
925 return yield* new SshLaunchError({
926 message: "SSH launch did not return a remote port.",
927 stdout: result.stdout,
928 });
929 }
930 const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe(
931 Effect.mapError(
932 (cause) =>
933 new SshLaunchError({
934 message: "SSH launch returned unparseable output.",
935 stdout: result.stdout,
936 cause,
937 }),
938 ),
939 );
940 if (!Number.isInteger(parsed.remotePort)) {
941 return yield* new SshLaunchError({
942 message: `SSH launch returned an invalid remote port: ${String(parsed.remotePort)}.`,
943 stdout: result.stdout,
944 });
945 }
946 yield* Effect.logInfo("ssh.remoteServer.launch.ready", {
947 ...sshTargetLogFields(target),
948 remotePort: parsed.remotePort,
949 remoteServerKind: parsed.serverKind ?? null,
950 stateKey,
951 });
952 return {
953 remotePort: parsed.remotePort,
954 remoteServerKind: parsed.serverKind ?? null,
955 };
956 },
957);
958
959export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingToken")(function* (
960 target: DesktopSshEnvironmentTarget,
961 input?: SshAuth.SshAuthOptions,
962 runner?: RemoteT3RunnerOptions,
963): Effect.fn.Return<
964 {
965 readonly credential: string;
966 },
967 SshCommandError | SshInvalidTargetError | SshPairingError,
968 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
969> {
970 const stateKey = yield* remoteStateKey(target);
971 yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", {
972 ...sshTargetLogFields(target),
973 stateKey,
974 });
975 const result = yield* runSshCommand(target, {
976 remoteCommandArgs: ["sh", "-s"],
977 stdin: buildRemotePairingScript(stateKey, runner),
978 // Pairing may be the first command on a cold remote, so it can install
979 // the archive on the way.
980 ...(isNodeScriptRunner(runner) ? {} : { timeoutMs: REMOTE_ARCHIVE_LAUNCH_TIMEOUT_MS }),
981 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
982 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
983 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
984 });
985 if (!getLastNonEmptyOutputLine(result.stdout)) {
986 return yield* new SshPairingError({
987 message: "SSH pairing did not return a credential.",
988 stdout: result.stdout,
989 });
990 }
991 const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe(
992 Effect.mapError(
993 (cause) =>
994 new SshPairingError({
995 message: "SSH pairing returned unparseable output.",
996 stdout: result.stdout,
997 cause,
998 }),
999 ),
1000 );
1001 if (parsed.credential.trim().length === 0) {
1002 return yield* new SshPairingError({
1003 message: "SSH pairing command returned an invalid credential.",
1004 stdout: result.stdout,
1005 });
1006 }
1007 yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", {
1008 ...sshTargetLogFields(target),
1009 stateKey,
1010 });
1011 return {
1012 credential: parsed.credential,
1013 };
1014});
1015
1016const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* (
1017 target: DesktopSshEnvironmentTarget,
1018 input?: SshAuth.SshAuthOptions,
1019): Effect.fn.Return<
1020 void,
1021 SshCommandError | SshInvalidTargetError,
1022 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
1023> {
1024 const stateKey = yield* remoteStateKey(target);
1025 yield* Effect.logInfo("ssh.remoteServer.stop.start", {
1026 ...sshTargetLogFields(target),
1027 stateKey,
1028 });
1029 yield* runSshCommand(target, {
1030 remoteCommandArgs: ["sh", "-s"],
1031 stdin: buildRemoteStopScript(stateKey),
1032 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
1033 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
1034 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
1035 });
1036 yield* Effect.logInfo("ssh.remoteServer.stop.succeeded", {
1037 ...sshTargetLogFields(target),
1038 stateKey,
1039 });
1040});
1041
1042const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(function* (
1043 target: DesktopSshEnvironmentTarget,
1044 input?: SshAuth.SshAuthOptions,
1045): Effect.fn.Return<
1046 string,
1047 SshCommandError | SshInvalidTargetError,
1048 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem | Path.Path
1049> {
1050 const result = yield* runSshCommand(target, {
1051 remoteCommandArgs: ["sh", "-s"],
1052 stdin: buildRemoteLogTailScript(yield* remoteStateKey(target)),
1053 timeoutMs: 10_000,
1054 ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
1055 ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
1056 ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
1057 });
1058 return result.stdout.trim();
1059});
1060
1061export const waitForHttpReady = (input: {
1062 readonly baseUrl: string;
1063 readonly timeoutMs?: number;
1064 readonly intervalMs?: number;
1065 readonly probeTimeoutMs?: number;
1066 readonly path?: string;
1067}): Effect.Effect<void, SshReadinessError, HttpClient.HttpClient> =>
1068 waitForHttpReadyShared({
1069 baseUrl: input.baseUrl,
1070 ...(input.path === undefined ? {} : { path: input.path }),
1071 ...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
1072 ...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }),
1073 probeTimeoutMs: input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS,
1074 makeError: ({ requestUrl, probeTimeoutMs, cause }) => {
1075 if (typeof cause === "object" && cause !== null && "kind" in cause) {
1076 const kind = (cause as { readonly kind?: unknown }).kind;
1077 if (kind === "probe-timeout") {
1078 return new SshReadinessError({
1079 message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`,
1080 cause,
1081 });
1082 }
1083 if (kind === "overall-timeout") {
1084 const overall = cause as unknown as {
1085 readonly baseUrl: string;
1086 readonly timeoutMs: number;
1087 readonly lastFailure: unknown;
1088 };
1089 return new SshReadinessError({
1090 message: `Timed out waiting ${overall.timeoutMs}ms for backend readiness at ${overall.baseUrl}.`,
1091 cause: overall.lastFailure,
1092 });
1093 }
1094 }
1095 return new SshReadinessError({
1096 message: `Backend readiness probe failed at ${requestUrl}.`,
1097 cause,
1098 });
1099 },
1100 });
1101
1102function isLoopbackHostname(hostname: string): boolean {
1103 const normalized = hostname
1104 .trim()
1105 .toLowerCase()
1106 .replace(/^\[(.*)\]$/, "$1");
1107 return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
1108}
1109
1110export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")(
1111 function* (rawHttpBaseUrl: unknown): Effect.fn.Return<string, SshHttpBridgeError> {
1112 return yield* Effect.try({
1113 try: () => {
1114 if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) {
1115 throw new Error("Invalid SSH forwarded http base URL.");
1116 }
1117 const baseUrl = new URL(rawHttpBaseUrl);
1118 if (!isLoopbackHostname(baseUrl.hostname)) {
1119 throw new Error("SSH desktop bridge only supports loopback forwarded URLs.");
1120 }
1121 return baseUrl.toString();
1122 },
1123 catch: (cause) =>
1124 new SshHttpBridgeError({
1125 message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.",
1126 cause,
1127 }),
1128 });
1129 },
1130);
1131
1132const reserveLocalTunnelPort = Effect.fn("ssh/tunnel.reserveLocalTunnelPort")(function* () {
1133 const net = yield* NetService.NetService;
1134 return yield* net.reserveLoopbackPort();
1135});
1136
1137const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: {
1138 readonly key: string;
1139 readonly resolvedTarget: DesktopSshEnvironmentTarget;
1140 readonly remotePort: number;
1141 readonly localPort: number;
1142 readonly httpBaseUrl: string;
1143 readonly wsBaseUrl: string;
1144 readonly authOptions: SshAuth.SshAuthOptions;
1145 readonly remoteServerKind: "external" | "managed" | null;
1146 readonly scope: Scope.Closeable;
1147}): Effect.fn.Return<
1148 SshTunnelEntry,
1149 SshCommandError | SshInvalidTargetError | SshReadinessError,
1150 | ChildProcessSpawner.ChildProcessSpawner
1151 | Crypto.Crypto
1152 | FileSystem.FileSystem
1153 | Path.Path
1154 | HttpClient.HttpClient
1155 | NetService.NetService
1156> {
1157 const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget);
1158 const childEnvironment = yield* SshAuth.buildSshChildEnvironment({
1159 ...(input.authOptions.authSecret === undefined
1160 ? {}
1161 : { authSecret: input.authOptions.authSecret }),
1162 ...(input.authOptions.interactiveAuth === undefined
1163 ? {}
1164 : { interactiveAuth: input.authOptions.interactiveAuth }),
1165 }).pipe(
1166 Effect.mapError(
1167 (cause) =>
1168 new SshCommandError({
1169 command: ["ssh"],
1170 exitCode: null,
1171 stderr: "",
1172 message: "Failed to prepare SSH authentication helpers.",
1173 cause,
1174 }),
1175 ),
1176 );
1177 const args = [
1178 ...baseSshArgs(input.resolvedTarget, {
1179 batchMode: input.authOptions.batchMode ?? "no",
1180 }),
1181 "-o",
1182 "ExitOnForwardFailure=yes",
1183 "-o",
1184 "ControlMaster=no",
1185 "-o",
1186 "ControlPath=none",
1187 "-o",
1188 "ControlPersist=no",
1189 "-o",
1190 "ServerAliveInterval=15",
1191 "-o",
1192 "ServerAliveCountMax=3",
1193 "-n",
1194 "-N",
1195 "-L",
1196 `${input.localPort}:127.0.0.1:${input.remotePort}`,
1197 hostSpec,
1198 ];
1199 const sshCommand = yield* resolveSshCommand;
1200 const tunnelCommand = [sshCommand, ...args];
1201 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1202 const { scope } = input;
1203 yield* Effect.logDebug("ssh.tunnel.spawn.start", {
1204 ...sshTargetLogFields(input.resolvedTarget),
1205 command: tunnelCommand,
1206 localPort: input.localPort,
1207 remotePort: input.remotePort,
1208 remoteServerKind: input.remoteServerKind,
1209 httpBaseUrl: input.httpBaseUrl,
1210 });
1211 const child = yield* spawner
1212 .spawn(
1213 ChildProcess.make(sshCommand, args, {
1214 env: childEnvironment,
1215 extendEnv: true,
1216 stdin: {
1217 stream: Stream.empty,
1218 endOnDone: true,
1219 },
1220 }),
1221 )
1222 .pipe(
1223 Effect.provideService(Scope.Scope, scope),
1224 Effect.mapError(
1225 (cause) =>
1226 new SshCommandError({
1227 command: tunnelCommand,
1228 exitCode: null,
1229 stderr: "",
1230 message:
1231 cause instanceof Error
1232 ? cause.message
1233 : `Failed to spawn SSH tunnel for ${input.resolvedTarget.alias}.`,
1234 cause,
1235 }),
1236 ),
1237 );
1238 yield* Effect.logDebug("ssh.tunnel.spawn.succeeded", {
1239 ...sshTargetLogFields(input.resolvedTarget),
1240 command: tunnelCommand,
1241 pid: child.pid,
1242 localPort: input.localPort,
1243 remotePort: input.remotePort,
1244 httpBaseUrl: input.httpBaseUrl,
1245 });
1246 const tunnelEntry: SshTunnelEntry = {
1247 key: input.key,
1248 target: input.resolvedTarget,
1249 remotePort: input.remotePort,
1250 remoteServerKind: input.remoteServerKind,
1251 localPort: input.localPort,
1252 httpBaseUrl: input.httpBaseUrl,
1253 wsBaseUrl: input.wsBaseUrl,
1254 process: child,
1255 scope,
1256 };
1257 const exitFailure = Effect.all(
1258 [collectProcessOutput(child.stderr), child.exitCode.pipe(Effect.map(Number))],
1259 { concurrency: "unbounded" },
1260 ).pipe(
1261 Effect.mapError(
1262 (cause) =>
1263 new SshCommandError({
1264 command: tunnelCommand,
1265 exitCode: null,
1266 stderr: "",
1267 message:
1268 cause instanceof Error
1269 ? cause.message
1270 : `Failed to monitor SSH tunnel for ${input.resolvedTarget.alias}.`,
1271 cause,
1272 }),
1273 ),
1274 Effect.flatMap(([stderr, exitCode]) => {
1275 const error = new SshCommandError({
1276 command: tunnelCommand,
1277 exitCode,
1278 stderr,
1279 message: normalizeSshErrorMessage(
1280 stderr,
1281 `SSH tunnel exited unexpectedly for ${input.resolvedTarget.alias} (exit ${exitCode}).`,
1282 ),
1283 });
1284 return Effect.logWarning("ssh.tunnel.process.exited", {
1285 ...sshTargetLogFields(input.resolvedTarget),
1286 command: tunnelCommand,
1287 pid: child.pid,
1288 localPort: input.localPort,
1289 remotePort: input.remotePort,
1290 httpBaseUrl: input.httpBaseUrl,
1291 exitCode,
1292 stderr,
1293 }).pipe(Effect.andThen(Effect.fail(error)));
1294 }),
1295 );
1296 yield* Effect.raceFirst(
1297 waitForHttpReady({
1298 baseUrl: input.httpBaseUrl,
1299 timeoutMs: SSH_READY_TIMEOUT_MS,
1300 }),
1301 exitFailure,
1302 ).pipe(
1303 Effect.tap(() =>
1304 Effect.logInfo("ssh.tunnel.ready", {
1305 ...sshTargetLogFields(input.resolvedTarget),
1306 command: tunnelCommand,
1307 pid: child.pid,
1308 localPort: input.localPort,
1309 remotePort: input.remotePort,
1310 httpBaseUrl: input.httpBaseUrl,
1311 }),
1312 ),
1313 Effect.tapError((cause) =>
1314 Effect.gen(function* () {
1315 const net = yield* NetService.NetService;
1316 const processRunningExit = yield* Effect.exit(child.isRunning);
1317 const localPortAvailableExit = yield* Effect.exit(
1318 net.canListenOnHost(input.localPort, "127.0.0.1"),
1319 );
1320 const remoteLogTailExit = yield* Effect.exit(
1321 readRemoteServerLogTail(input.resolvedTarget, input.authOptions),
1322 );
1323 const processRunning = Exit.isSuccess(processRunningExit) ? processRunningExit.value : null;
1324 const localPortAvailable = Exit.isSuccess(localPortAvailableExit)
1325 ? localPortAvailableExit.value
1326 : null;
1327 const remoteLogTail = Exit.isSuccess(remoteLogTailExit)
1328 ? remoteLogTailExit.value || null
1329 : null;
1330 yield* Effect.logWarning("ssh.tunnel.ready.failed", {
1331 ...sshTargetLogFields(input.resolvedTarget),
1332 command: tunnelCommand,
1333 pid: child.pid,
1334 processRunning,
1335 ...(Exit.isSuccess(processRunningExit)
1336 ? {}
1337 : { processRunningError: processRunningExit.cause }),
1338 localPort: input.localPort,
1339 localPortListening: localPortAvailable === null ? null : !localPortAvailable,
1340 remotePort: input.remotePort,
1341 httpBaseUrl: input.httpBaseUrl,
1342 ...(Exit.isSuccess(localPortAvailableExit)
1343 ? {}
1344 : { localPortProbeError: localPortAvailableExit.cause }),
1345 ...(remoteLogTail === null ? {} : { remoteLogTail }),
1346 ...(Exit.isSuccess(remoteLogTailExit)
1347 ? {}
1348 : { remoteLogTailError: remoteLogTailExit.cause }),
1349 cause,
1350 });
1351 }),
1352 ),
1353 Effect.onExit((exit) =>
1354 Exit.isSuccess(exit)
1355 ? Effect.void
1356 : child
1357 .kill({
1358 killSignal: "SIGTERM",
1359 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
1360 })
1361 .pipe(Effect.ignore),
1362 ),
1363 );
1364 return tunnelEntry;
1365});
1366
1367const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* (
1368 options: SshEnvironmentManagerOptions = {},
1369): Effect.fn.Return<SshEnvironmentManagerShape, never, Scope.Scope> {
1370 const managerScope = yield* Scope.Scope;
1371 const tunnels = new Map<string, SshTunnelEntry>();
1372 const targetLocks = yield* KeyedLock.make<string>();
1373 const authSecrets = new Map<string, string>();
1374
1375 // Keep one lock per target so reconnect cannot reuse a server while stop is pending.
1376 const withTargetLock = Effect.fn("ssh/tunnel.withTargetLock")(function* <A, E, R>(
1377 key: string,
1378 effect: Effect.Effect<A, E, R>,
1379 ): Effect.fn.Return<A, E, R> {
1380 return yield* targetLocks.withLock(key, effect);
1381 });
1382
1383 const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* (
1384 entry: SshTunnelEntry,
1385 ) {
1386 yield* Effect.logDebug("ssh.tunnel.close.start", {
1387 ...sshTargetLogFields(entry.target),
1388 key: entry.key,
1389 localPort: entry.localPort,
1390 remotePort: entry.remotePort,
1391 });
1392 yield* Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
1393 yield* Effect.logInfo("ssh.tunnel.close.succeeded", {
1394 ...sshTargetLogFields(entry.target),
1395 key: entry.key,
1396 localPort: entry.localPort,
1397 remotePort: entry.remotePort,
1398 });
1399 });
1400
1401 yield* Scope.addFinalizer(
1402 managerScope,
1403 Effect.sync(() => [...tunnels.values()]).pipe(
1404 Effect.flatMap((entries) =>
1405 Effect.forEach(entries, closeTunnelEntry, { concurrency: "unbounded" }),
1406 ),
1407 Effect.ignore,
1408 ),
1409 );
1410
1411 const promptForPassword = Effect.fn("ssh/tunnel.promptForPassword")(function* (
1412 target: DesktopSshEnvironmentTarget,
1413 attempt: number,
1414 ): Effect.fn.Return<
1415 string,
1416 SshInvalidTargetError | SshPasswordPromptError,
1417 SshAuth.SshPasswordPrompt
1418 > {
1419 const promptService = yield* SshAuth.SshPasswordPrompt;
1420 const hostSpec = yield* buildSshHostSpecEffect(target);
1421 if (!promptService.isAvailable) {
1422 yield* Effect.logWarning("ssh.auth.passwordPrompt.unavailable", {
1423 ...sshTargetLogFields(target),
1424 attempt,
1425 });
1426 return yield* new SshPasswordPromptError({
1427 message: `SSH authentication failed for ${hostSpec}.`,
1428 });
1429 }
1430
1431 yield* Effect.logInfo("ssh.auth.passwordPrompt.request", {
1432 ...sshTargetLogFields(target),
1433 attempt,
1434 });
1435 const password = yield* promptService.request({
1436 attempt,
1437 destination: target.alias.trim() || target.hostname.trim(),
1438 username: target.username,
1439 prompt: `Enter the SSH password for ${hostSpec}.`,
1440 });
1441 if (password === null) {
1442 yield* Effect.logWarning("ssh.auth.passwordPrompt.cancelled", {
1443 ...sshTargetLogFields(target),
1444 attempt,
1445 });
1446 return yield* new SshPasswordPromptError({
1447 message: `SSH authentication cancelled for ${hostSpec}.`,
1448 });
1449 }
1450 yield* Effect.logInfo("ssh.auth.passwordPrompt.received", {
1451 ...sshTargetLogFields(target),
1452 attempt,
1453 });
1454 return password;
1455 });
1456
1457 const handleSshAuthFailure = Effect.fn("ssh/tunnel.runWithSshAuthAttempt.handleFailure")(
1458 function* <T>(
1459 input: SshAuthAttemptInput<T> & {
1460 readonly error: SshEnvironmentEffectError;
1461 },
1462 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1463 if (!SshAuth.isSshAuthFailure(input.error)) {
1464 return yield* input.error;
1465 }
1466
1467 yield* Effect.logWarning("ssh.auth.failed", {
1468 ...sshTargetLogFields(input.target),
1469 key: input.key,
1470 promptCount: input.promptCount,
1471 cause: input.error,
1472 });
1473 const promptService = yield* SshAuth.SshPasswordPrompt;
1474 if (!promptService.isAvailable) {
1475 return yield* input.error;
1476 }
1477 if (input.authSecret !== null) {
1478 authSecrets.delete(input.key);
1479 }
1480 if (input.promptCount >= 2) {
1481 return yield* input.error;
1482 }
1483
1484 const nextPromptCount = input.promptCount + 1;
1485 const nextAuthSecret = yield* promptForPassword(input.target, nextPromptCount);
1486 authSecrets.set(input.key, nextAuthSecret);
1487 return yield* runWithSshAuthAttempt({
1488 ...input,
1489 promptCount: nextPromptCount,
1490 authSecret: nextAuthSecret,
1491 });
1492 },
1493 );
1494
1495 const runWithSshAuthAttempt = Effect.fn("ssh/tunnel.runWithSshAuthAttempt")(function* <T>(
1496 input: SshAuthAttemptInput<T>,
1497 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1498 const promptService = yield* SshAuth.SshPasswordPrompt;
1499 const authOptions =
1500 input.authSecret === null
1501 ? {
1502 batchMode: promptService.isAvailable ? ("yes" as const) : ("no" as const),
1503 interactiveAuth: !promptService.isAvailable,
1504 }
1505 : {
1506 authSecret: input.authSecret,
1507 batchMode: "no" as const,
1508 interactiveAuth: true,
1509 };
1510
1511 return yield* input
1512 .operation(authOptions)
1513 .pipe(Effect.catch((error) => handleSshAuthFailure({ ...input, error })));
1514 });
1515
1516 const runWithSshAuth = Effect.fn("ssh/tunnel.runWithSshAuth")(function* <T>(
1517 input: SshAuthOperationInput<T>,
1518 ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1519 return yield* runWithSshAuthAttempt({
1520 ...input,
1521 promptCount: 0,
1522 authSecret: authSecrets.get(input.key) ?? null,
1523 });
1524 });
1525
1526 const createTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry.create")(function* (input: {
1527 readonly key: string;
1528 readonly resolvedTarget: DesktopSshEnvironmentTarget;
1529 readonly runner?: RemoteT3RunnerOptions;
1530 }): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1531 yield* Effect.logDebug("ssh.environment.tunnel.create.start", {
1532 ...sshTargetLogFields(input.resolvedTarget),
1533 ...sshRunnerLogFields(input.runner),
1534 key: input.key,
1535 });
1536 const remoteLaunch = yield* runWithSshAuth({
1537 key: input.key,
1538 target: input.resolvedTarget,
1539 operation: (authOptions) =>
1540 launchOrReuseRemoteServer(input.resolvedTarget, authOptions, input.runner),
1541 });
1542 const remotePort = remoteLaunch.remotePort;
1543 yield* Effect.logDebug("ssh.environment.remotePort.ready", {
1544 ...sshTargetLogFields(input.resolvedTarget),
1545 key: input.key,
1546 remotePort,
1547 remoteServerKind: remoteLaunch.remoteServerKind,
1548 });
1549 const localPort = yield* reserveLocalTunnelPort();
1550 const httpBaseUrl = `http://127.0.0.1:${localPort}/`;
1551 const wsBaseUrl = `ws://127.0.0.1:${localPort}/`;
1552 yield* Effect.logDebug("ssh.environment.localPort.reserved", {
1553 ...sshTargetLogFields(input.resolvedTarget),
1554 key: input.key,
1555 localPort,
1556 remotePort,
1557 });
1558 const entryScope = yield* Scope.make("sequential");
1559 const tunnelEntry = yield* runWithSshAuth({
1560 key: input.key,
1561 target: input.resolvedTarget,
1562 operation: (authOptions) =>
1563 startSshTunnel({
1564 key: input.key,
1565 resolvedTarget: input.resolvedTarget,
1566 remotePort,
1567 localPort,
1568 httpBaseUrl,
1569 wsBaseUrl,
1570 authOptions,
1571 remoteServerKind: remoteLaunch.remoteServerKind,
1572 scope: entryScope,
1573 }),
1574 }).pipe(
1575 Effect.onExit((exit) =>
1576 Exit.isSuccess(exit) ? Effect.void : Scope.close(entryScope, Exit.void).pipe(Effect.ignore),
1577 ),
1578 );
1579 tunnels.set(input.key, tunnelEntry);
1580 const spawnerService = yield* ChildProcessSpawner.ChildProcessSpawner;
1581 const cryptoService = yield* Crypto.Crypto;
1582 const fileSystemService = yield* FileSystem.FileSystem;
1583 const pathService = yield* Path.Path;
1584 yield* Scope.addFinalizer(
1585 entryScope,
1586 Effect.gen(function* () {
1587 const stopRemote = tunnels.get(tunnelEntry.key) === tunnelEntry;
1588 if (stopRemote) {
1589 tunnels.delete(tunnelEntry.key);
1590 }
1591 yield* tunnelEntry.process
1592 .kill({
1593 killSignal: "SIGTERM",
1594 forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS,
1595 })
1596 .pipe(Effect.ignore);
1597 if (!stopRemote) {
1598 return;
1599 }
1600 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", {
1601 ...sshTargetLogFields(tunnelEntry.target),
1602 key: tunnelEntry.key,
1603 localPort: tunnelEntry.localPort,
1604 remotePort: tunnelEntry.remotePort,
1605 });
1606 const authSecret = authSecrets.get(tunnelEntry.key) ?? null;
1607 yield* stopRemoteServer(
1608 tunnelEntry.target,
1609 authSecret === null
1610 ? {
1611 batchMode: "yes",
1612 interactiveAuth: false,
1613 }
1614 : {
1615 authSecret,
1616 batchMode: "no",
1617 interactiveAuth: true,
1618 },
1619 ).pipe(
1620 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService),
1621 Effect.provideService(Crypto.Crypto, cryptoService),
1622 Effect.provideService(FileSystem.FileSystem, fileSystemService),
1623 Effect.provideService(Path.Path, pathService),
1624 );
1625 yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", {
1626 ...sshTargetLogFields(tunnelEntry.target),
1627 key: tunnelEntry.key,
1628 localPort: tunnelEntry.localPort,
1629 remotePort: tunnelEntry.remotePort,
1630 });
1631 }).pipe(Effect.ignore),
1632 );
1633 yield* Effect.logDebug("ssh.environment.tunnel.create.succeeded", {
1634 ...sshTargetLogFields(input.resolvedTarget),
1635 key: input.key,
1636 localPort,
1637 remotePort,
1638 });
1639 return tunnelEntry;
1640 });
1641
1642 const ensureTunnelEntry = Effect.fn("ssh/tunnel.ensureTunnelEntry")(function* (
1643 key: string,
1644 resolvedTarget: DesktopSshEnvironmentTarget,
1645 runner?: RemoteT3RunnerOptions,
1646 ): Effect.fn.Return<SshTunnelEntry, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1647 const entry = tunnels.get(key) ?? null;
1648
1649 if (entry !== null) {
1650 yield* Effect.logDebug("ssh.environment.tunnel.existing.check", {
1651 ...sshTargetLogFields(resolvedTarget),
1652 key,
1653 localPort: entry.localPort,
1654 remotePort: entry.remotePort,
1655 });
1656 const readinessExit = yield* Effect.exit(
1657 waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }),
1658 );
1659 if (Exit.isSuccess(readinessExit)) {
1660 yield* Effect.logDebug("ssh.environment.tunnel.reused", {
1661 ...sshTargetLogFields(resolvedTarget),
1662 key,
1663 localPort: entry.localPort,
1664 remotePort: entry.remotePort,
1665 });
1666 return entry;
1667 }
1668 yield* Effect.logWarning("ssh.environment.tunnel.existing.stale", {
1669 ...sshTargetLogFields(resolvedTarget),
1670 key,
1671 localPort: entry.localPort,
1672 remotePort: entry.remotePort,
1673 cause: readinessExit.cause,
1674 });
1675 // Closing a stale forward must not run the manager-owned remote stop.
1676 tunnels.delete(key);
1677 yield* closeTunnelEntry(entry);
1678 }
1679
1680 return yield* createTunnelEntry({
1681 key,
1682 resolvedTarget,
1683 ...(runner === undefined ? {} : { runner }),
1684 }).pipe(
1685 Effect.tapError((cause) =>
1686 Effect.logWarning("ssh.environment.tunnel.create.failed", {
1687 ...sshTargetLogFields(resolvedTarget),
1688 key,
1689 cause,
1690 }),
1691 ),
1692 );
1693 });
1694
1695 const ensureEnvironment = Effect.fn("ssh/tunnel.ensureEnvironment")(function* (
1696 target: DesktopSshEnvironmentTarget,
1697 requestOptions?: { readonly issuePairingToken?: boolean },
1698 ): Effect.fn.Return<
1699 DesktopSshEnvironmentBootstrap,
1700 SshEnvironmentEffectError,
1701 SshEnvironmentEffectContext
1702 > {
1703 yield* Effect.logInfo("ssh.environment.ensure.start", {
1704 ...sshTargetLogFields(target),
1705 issuePairingToken: requestOptions?.issuePairingToken === true,
1706 });
1707 const baseResolved = yield* resolveSshTarget(target.alias || target.hostname);
1708 const resolvedTarget: DesktopSshEnvironmentTarget = {
1709 ...baseResolved,
1710 ...(target.username !== null ? { username: target.username } : {}),
1711 ...(target.port !== null ? { port: target.port } : {}),
1712 };
1713 const key = targetConnectionKey(resolvedTarget);
1714 yield* Effect.logDebug("ssh.environment.target.resolved", {
1715 ...sshTargetLogFields(resolvedTarget),
1716 key,
1717 });
1718 const runner =
1719 options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner;
1720 yield* Effect.logDebug("ssh.environment.runner.resolved", {
1721 ...sshTargetLogFields(resolvedTarget),
1722 ...sshRunnerLogFields(runner),
1723 key,
1724 });
1725 return yield* withTargetLock(
1726 key,
1727 Effect.gen(function* () {
1728 const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner);
1729
1730 const pairingResult = requestOptions?.issuePairingToken
1731 ? yield* runWithSshAuth({
1732 key,
1733 target: entry.target,
1734 operation: (authOptions) =>
1735 issueRemotePairingToken(entry.target, authOptions, runner),
1736 })
1737 : null;
1738 const pairingToken = pairingResult?.credential ?? null;
1739
1740 yield* Effect.logInfo("ssh.environment.ensure.succeeded", {
1741 ...sshTargetLogFields(entry.target),
1742 key,
1743 localPort: entry.localPort,
1744 remotePort: entry.remotePort,
1745 remoteServerKind: entry.remoteServerKind,
1746 issuedPairingToken: pairingToken !== null,
1747 });
1748 return {
1749 target: entry.target,
1750 httpBaseUrl: entry.httpBaseUrl,
1751 wsBaseUrl: entry.wsBaseUrl,
1752 pairingToken,
1753 remotePort: entry.remotePort,
1754 ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}),
1755 };
1756 }),
1757 );
1758 });
1759
1760 const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* (
1761 target: DesktopSshEnvironmentTarget,
1762 ): Effect.fn.Return<void, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
1763 yield* Effect.logInfo("ssh.environment.disconnect.start", sshTargetLogFields(target));
1764 const baseResolved = yield* resolveSshTarget(target.alias || target.hostname);
1765 const resolvedTarget: DesktopSshEnvironmentTarget = {
1766 ...baseResolved,
1767 ...(target.username !== null ? { username: target.username } : {}),
1768 ...(target.port !== null ? { port: target.port } : {}),
1769 };
1770 const key = targetConnectionKey(resolvedTarget);
1771 yield* withTargetLock(
1772 key,
1773 Effect.gen(function* () {
1774 const entry = tunnels.get(key) ?? null;
1775 yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", {
1776 ...sshTargetLogFields(resolvedTarget),
1777 key,
1778 hasTunnel: entry !== null,
1779 });
1780 if (entry !== null) {
1781 // Explicit disconnect owns the remote stop so its failure reaches the caller.
1782 yield* Effect.gen(function* () {
1783 tunnels.delete(key);
1784 yield* closeTunnelEntry(entry);
1785 }).pipe(Effect.uninterruptible);
1786 }
1787 yield* runWithSshAuth({
1788 key,
1789 target: resolvedTarget,
1790 operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions),
1791 });
1792 yield* Effect.logInfo("ssh.environment.disconnect.succeeded", {
1793 ...sshTargetLogFields(resolvedTarget),
1794 key,
1795 });
1796 }),
1797 );
1798 });
1799
1800 return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment });
1801});
1802
1803/**
1804 * @effect-expect-leaking ChildProcessSpawner | Crypto | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt
1805 */
1806export class SshEnvironmentManager extends Context.Service<
1807 SshEnvironmentManager,
1808 SshEnvironmentManagerShape
1809>()("@t3tools/ssh/tunnel/SshEnvironmentManager") {
1810 static readonly layer = (options: SshEnvironmentManagerOptions = {}) =>
1811 Layer.effect(SshEnvironmentManager, makeSshEnvironmentManager(options));
1812}