# Files changed · PR \#22146 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22146/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(ssh\): Tailscale SSH check links open in the browser instead of a stuck reconnect](https://git.cafe/pingdotgg/t3code/pull/22146.md)

Version: 9

Head commit: f97bc69f0762ade9b55dbcece2faba0f44b527cb

Base commit: bd6940beab9e04a843d711fca812067c22991ab4

Comparison base: bd6940beab9e04a843d711fca812067c22991ab4

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/desktop/src/ssh/DesktopSshEnvironment.ts

Status: modified; +12; −1

```diff
@@ -26,6 +26,7 @@
 import * as HttpClient from "effect/http/HttpClient";
 import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
 
+import * as ElectronShell from "../electron/ElectronShell.ts";
 import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts";
 
 export type DesktopSshEnvironmentRuntimeServices =
@@ -119,18 +120,28 @@
 
 const makePasswordPrompt = (
   prompts: DesktopSshPasswordPrompts.DesktopSshPasswordPrompts["Service"],
+  shell: ElectronShell.ElectronShell["Service"],
 ): SshAuth.SshPasswordPrompt["Service"] => ({
   isAvailable: true,
   request: (request: SshAuth.SshPasswordRequest) =>
     prompts.request(request).pipe(Effect.mapError(toSshPasswordPromptError)),
+  openApprovalUrl: (url) =>
+    shell
+      .openExternal(url)
+      .pipe(
+        Effect.flatMap((opened) =>
+          opened ? Effect.void : Effect.logWarning("desktop.ssh.approvalUrl.openFailed"),
+        ),
+      ),
 });
 
 /** @public Service construction is part of the canonical Effect module API. */
 export const make = Effect.gen(function* () {
   const manager = yield* SshTunnel.SshEnvironmentManager;
   const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts;
+  const shell = yield* ElectronShell.ElectronShell;
   const runtimeContext = yield* Effect.context<DesktopSshEnvironmentRuntimeServices>();
+  const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts, shell));
-  const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts));
 
   return DesktopSshEnvironment.of({
     discoverHosts: (input) =>
```

## packages/ssh/src/auth.ts

Status: modified; +5; −0

```diff
@@ -32,6 +32,8 @@
   readonly authSecret?: string | null;
   readonly batchMode?: "yes" | "no";
   readonly interactiveAuth?: boolean;
+  /** Receives the approval link while Tailscale SSH holds the session for a check. */
+  readonly onTailscaleCheck?: (url: string) => Effect.Effect<void>;
 }
 
 export interface SshPasswordPromptShape {
@@ -39,6 +41,8 @@
   readonly request: (
     request: SshPasswordRequest,
   ) => Effect.Effect<string | null, SshPasswordPromptError>;
+  /** Shows an approval link that SSH printed while it waits for the user. */
+  readonly openApprovalUrl: (url: string) => Effect.Effect<void>;
 }
 
 export class SshPasswordPrompt extends Context.Service<SshPasswordPrompt, SshPasswordPromptShape>()(
@@ -49,6 +53,7 @@
     SshPasswordPrompt.of({
       isAvailable: false,
       request: () => Effect.succeed(null),
+      openApprovalUrl: () => Effect.void,
     }),
   );
 }
```

## packages/ssh/src/command.test.ts

Status: modified; +68; −0

```diff
@@ -1,10 +1,14 @@
 import { assert, describe, it } from "@effect/vitest";
 import * as NodeServices from "@effect/platform-node/NodeServices";
+import * as Deferred from "effect/Deferred";
+import * as Duration from "effect/Duration";
 import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
 import * as Layer from "effect/Layer";
 import * as Result from "effect/Result";
 import * as Sink from "effect/Sink";
 import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
 import { ChildProcess, ChildProcessSpawner } from "effect/process";
 
 import { parseSshResolveOutput, remoteStateKey, runSshCommand } from "./command.ts";
@@ -30,6 +34,31 @@
   });
 };
 
+const makeNeverFinishingProcess = (stderr: Stream.Stream<Uint8Array> = Stream.empty) => {
+  let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null;
+  return ChildProcessSpawner.makeHandle({
+    pid: ChildProcessSpawner.ProcessId(123),
+    stdout: Stream.empty,
+    stderr,
+    all: Stream.empty,
+    exitCode: Effect.callback<ChildProcessSpawner.ExitCode>((resume) => {
+      finish = (exitCode) => resume(Effect.succeed(exitCode));
+      return Effect.sync(() => {
+        finish = null;
+      });
+    }),
+    isRunning: Effect.succeed(true),
+    kill: () =>
+      Effect.sync(() => {
+        finish?.(ChildProcessSpawner.ExitCode(143));
+      }),
+    stdin: Sink.drain,
+    getInputFd: () => Sink.drain,
+    getOutputFd: () => Stream.empty,
+    unref: Effect.succeed(Effect.void),
+  });
+};
+
 describe("ssh command", () => {
   it.effect("parses resolved ssh config output into a target", () =>
     Effect.sync(() => {
@@ -171,4 +200,43 @@
       }
     }).pipe(Effect.provide(layerProcess));
   });
+
+  it.effect("reports the Tailscale SSH check link while ssh waits for approval", () => {
+    const url = "https://login.tailscale.com/a/1a2b3c4d5e6f";
+    const reported: Array<string> = [];
+    return Effect.gen(function* () {
+      const stderrRead = yield* Deferred.make<void>();
+      // Tailscale holds the session open after printing the link; split the
+      // link across reads the way a pipe can.
+      const stderr = Stream.make(
+        "# Tailscale SSH requires an additional check.\r\n# To authenticate, visit: https://login.tailscale.com/a/1a2b",
+        "3c4d5e6f\r\n",
+        "# Tailscale SSH requires an additional check.\r\n",
+      ).pipe(
+        Stream.map((chunk) => encoder.encode(chunk)),
+        Stream.ensuring(Deferred.succeed(stderrRead, undefined)),
+      );
+      const spawner = ChildProcessSpawner.make(() =>
+        Effect.succeed(makeNeverFinishingProcess(stderr)),
+      );
+      const fiber = yield* Effect.forkChild(
+        Effect.result(
+          runSshCommand(
+            { alias: "devbox", hostname: "devbox", username: null, port: null },
+            {
+              timeoutMs: 1,
+              onTailscaleCheck: (link) => Effect.sync(() => reported.push(link)),
+            },
+          ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)),
+        ),
+      );
+      yield* Deferred.await(stderrRead);
+
+      assert.deepEqual(reported, [url]);
+
+      yield* TestClock.adjust(Duration.millis(1));
+      yield* Fiber.join(fiber);
+      assert.deepEqual(reported, [url]);
+    }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer())));
+  });
 });
```

## packages/ssh/src/command.ts

Status: modified; +26; −1

```diff
@@ -135,6 +135,31 @@
     ),
   );
 
+// Tailscale SSH "check" mode prints this link and holds the session open until
+// someone approves it, so stderr must be read while ssh is still running.
+const TAILSCALE_CHECK_URL_PATTERN =
+  /To authenticate, visit: (https:\/\/login\.tailscale\.com\/a\/[\w-]+)\r?\n/u;
+
+const collectSshStderr = <E>(
+  stream: Stream.Stream<Uint8Array, E>,
+  onTailscaleCheck: ((url: string) => Effect.Effect<void>) | undefined,
+): Effect.Effect<string, E> =>
+  onTailscaleCheck === undefined
+    ? collectProcessOutput(stream)
+    : stream.pipe(
+        Stream.decodeText(),
+        Stream.runFoldEffect(
+          () => "",
+          (acc, chunk) => {
+            const output = acc + chunk;
+            const url = TAILSCALE_CHECK_URL_PATTERN.exec(output)?.[1];
+            return url === undefined || TAILSCALE_CHECK_URL_PATTERN.test(acc)
+              ? Effect.succeed(output)
+              : onTailscaleCheck(url).pipe(Effect.as(output));
+          },
+        ),
+      );
+
 function redactSshErrorOutput(output: string, keepTail = false): string {
   const redacted = output.replace(
     /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu,
@@ -246,7 +271,7 @@
   const [stdout, stderr, exitCode] = yield* Effect.all(
     [
       collectProcessOutput(child.stdout),
+      collectSshStderr(child.stderr, input.onTailscaleCheck),
-      collectProcessOutput(child.stderr),
       child.exitCode.pipe(Effect.map(Number)),
     ],
     { concurrency: "unbounded" },
```

## packages/ssh/src/tunnel.test.ts

Status: modified; +64; −0

```diff
@@ -156,6 +156,70 @@
     }).pipe(Effect.provide(layerProcess));
   });
 
+  it.effect("opens the Tailscale SSH check link and hands it to retries while approval waits", () =>
+    Effect.gen(function* () {
+      const url = "https://login.tailscale.com/a/1a2b3c4d5e6f";
+      const stderrRead = yield* Deferred.make<void>();
+      const approved = yield* Deferred.make<void>();
+      const opened: Array<string> = [];
+      let launches = 0;
+      const spawner = ChildProcessSpawner.make((command) =>
+        Effect.sync(() => {
+          const args = commandArgs(command);
+          if (args.includes("-N")) return makeRunningProcess(() => {});
+          if (args.some((arg, index) => arg === "-s" && args[index + 1] === "--")) {
+            launches += 1;
+            // Tailscale SSH prints the link, then holds the session until it is approved.
+            return {
+              ...makeSuccessfulProcess('{"remotePort":3773}\n'),
+              stderr: Stream.make(
+                new TextEncoder().encode(
+                  `# Tailscale SSH requires an additional check.\n# To authenticate, visit: ${url}\n`,
+                ),
+              ).pipe(Stream.ensuring(Deferred.succeed(stderrRead, undefined))),
+              exitCode: Deferred.await(approved).pipe(Effect.as(ChildProcessSpawner.ExitCode(0))),
+            };
+          }
+          return makeSuccessfulProcess("");
+        }),
+      );
+      const layer = Layer.mergeAll(
+        NodeServices.layer,
+        Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
+        Layer.succeed(HttpClient.HttpClient, testHttpClient),
+        Layer.succeed(NetService.NetService, testNetService),
+        Layer.succeed(
+          SshAuth.SshPasswordPrompt,
+          SshAuth.SshPasswordPrompt.of({
+            isAvailable: true,
+            request: () => Effect.die("unexpected password prompt"),
+            openApprovalUrl: (link) => Effect.sync(() => opened.push(link)),
+          }),
+        ),
+        SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
+      );
+      yield* Effect.gen(function* () {
+        const manager = yield* SshTunnel.SshEnvironmentManager;
+        const target = { alias: "devbox", hostname: "devbox", username: null, port: null };
+        const first = yield* Effect.forkChild(manager.ensureEnvironment(target));
+        yield* Deferred.await(stderrRead);
+        assert.deepEqual(opened, [url]);
+
+        const retry = yield* Effect.result(manager.ensureEnvironment(target));
+        assert.isTrue(Result.isFailure(retry));
+        if (Result.isFailure(retry)) {
+          assert.include(retry.failure.message, url);
+        }
+
+        yield* Deferred.succeed(approved, undefined);
+        yield* Fiber.join(first);
+        yield* manager.ensureEnvironment(target);
+        assert.equal(launches, 1);
+        assert.deepEqual(opened, [url]);
+      }).pipe(Effect.provide(layer), Effect.scoped);
+    }),
+  );
+
   it.effect("retains a bounded diagnostic tail when the tunnel exits before readiness", () =>
     Effect.gen(function* () {
       const diagnostic = "Connection refused: café";
```

## packages/ssh/src/tunnel.ts

Status: modified; +33; −3

```diff
@@ -895,6 +895,9 @@
       ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
       ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
       ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
+      ...(input?.onTailscaleCheck === undefined
+        ? {}
+        : { onTailscaleCheck: input.onTailscaleCheck }),
     });
     if (!getLastNonEmptyOutputLine(result.stdout)) {
       return yield* new SshLaunchError({
@@ -954,6 +957,7 @@
     ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }),
     ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }),
     ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }),
+    ...(input?.onTailscaleCheck === undefined ? {} : { onTailscaleCheck: input.onTailscaleCheck }),
   });
   if (!getLastNonEmptyOutputLine(result.stdout)) {
     return yield* new SshPairingError({
@@ -1359,6 +1363,8 @@
   const tunnels = new Map<string, SshTunnelEntry>();
   const targetLocks = yield* KeyedLock.make<string>();
   const authSecrets = new Map<string, string>();
+  // Approval links for Tailscale SSH sessions that are waiting on the user.
+  const tailscaleChecks = new Map<string, string>();
 
   // Keep one lock per target so reconnect cannot reuse a server while stop is pending.
   const withTargetLock = Effect.fn("ssh/tunnel.withTargetLock")(function* <A, E, R>(
@@ -1484,21 +1490,34 @@
     input: SshAuthAttemptInput<T>,
   ): Effect.fn.Return<T, SshEnvironmentEffectError, SshEnvironmentEffectContext> {
     const promptService = yield* SshAuth.SshPasswordPrompt;
+    const onTailscaleCheck = Effect.fn("ssh/tunnel.runWithSshAuthAttempt.onTailscaleCheck")(
+      function* (url: string) {
+        tailscaleChecks.set(input.key, url);
+        yield* Effect.logInfo("ssh.auth.tailscaleCheck", {
+          ...sshTargetLogFields(input.target),
+          key: input.key,
+        });
+        yield* promptService.openApprovalUrl(url);
+      },
+    );
     const authOptions =
       input.authSecret === null
         ? {
             batchMode: promptService.isAvailable ? ("yes" as const) : ("no" as const),
             interactiveAuth: !promptService.isAvailable,
+            onTailscaleCheck,
           }
         : {
             authSecret: input.authSecret,
             batchMode: "no" as const,
             interactiveAuth: true,
+            onTailscaleCheck,
           };
 
+    return yield* input.operation(authOptions).pipe(
+      Effect.ensuring(Effect.sync(() => tailscaleChecks.delete(input.key))),
+      Effect.catch((error) => handleSshAuthFailure({ ...input, error })),
-    return yield* input
+    );
-      .operation(authOptions)
-      .pipe(Effect.catch((error) => handleSshAuthFailure({ ...input, error })));
   });
 
   const runWithSshAuth = Effect.fn("ssh/tunnel.runWithSshAuth")(function* <T>(
@@ -1703,6 +1722,17 @@
       ...sshTargetLogFields(resolvedTarget),
       key,
     });
+    // The held session outlasts a client's setup timeout, so a retry gets the
+    // approval link now instead of queuing behind that session.
+    const tailscaleCheck = tailscaleChecks.get(key);
+    if (tailscaleCheck !== undefined) {
+      return yield* new SshCommandError({
+        command: ["ssh"],
+        exitCode: null,
+        stderr: "",
+        message: `Tailscale SSH needs you to approve the connection to ${resolvedTarget.alias}: ${tailscaleCheck}`,
+      });
+    }
     const runner =
       options.resolveCliRunner === undefined ? undefined : yield* options.resolveCliRunner;
     yield* Effect.logDebug("ssh.environment.runner.resolved", {
```
