From 5d98ec8a3d87c60b6dc95539198a10a8f2654053 Mon Sep 17 00:00:00 2001 From: Ved Pandey Date: Fri, 25 Sep 2026 11:10:50 +0000 Subject: [PATCH 1/2] fix(editors): open remote projects over Tailscale SSH The server advertised SSH hosts for remote open links only after an sshd answered on its loopback port 22. Tailscale SSH serves only tailnet peers, so hosts that use it instead of OpenSSH showed "No SSH route" even though VS Code and Zed can connect to them. When no sshd answers, the server now runs `tailscale get --json ssh`. If Tailscale SSH is on, it advertises the MagicDNS name as a `tailscale-ssh` target with the account T3 Code runs as, and links name that account before the host. Tailscale SSH signs in as whichever account the client names, and editors otherwise send the local one. The desktop's Zed link check accepts that login the way vscode-remote links already do. Hosts with sshd advertise exactly what they did before. Older clients drop the new target kind and keep showing "No SSH route". --- .../src/electron/ElectronShell.test.ts | 43 +++- apps/desktop/src/electron/ElectronShell.ts | 19 +- .../src/environment/RemoteOpenTargets.test.ts | 195 ++++++++++++++++-- .../src/environment/RemoteOpenTargets.ts | 77 +++++-- apps/web/src/components/chat/OpenInPicker.tsx | 5 +- apps/web/src/remoteOpen.test.ts | 27 +++ apps/web/src/remoteOpen.ts | 2 + docs/user/remote-access.md | 17 ++ packages/contracts/src/editor.ts | 38 +++- packages/contracts/src/server.test.ts | 22 ++ packages/contracts/src/server.ts | 4 +- packages/shared/src/hostProcess.ts | 18 ++ packages/tailscale/src/tailscale.test.ts | 40 ++++ packages/tailscale/src/tailscale.ts | 149 ++++++++----- 14 files changed, 557 insertions(+), 99 deletions(-) diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 8ba8082f0e57..fd4367f10e54 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -106,6 +106,26 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens remote editor links that name an SSH login", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal( + "vscode://vscode-remote/ssh-remote+theo@example.com/home/user/project", + ), + electronShell.openExternal("zed://ssh/theo@example.com/home/user/project"), + ]); + + assert.deepEqual(results, [true, true]); + assert.deepEqual(openExternalMock.mock.calls, [ + ["vscode://vscode-remote/ssh-remote+theo@example.com/home/user/project"], + ["zed://ssh/theo@example.com/home/user/project"], + ]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open editor URLs that mix up link shapes", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -133,10 +153,29 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), - electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), + electronShell.openExternal("zed://user@ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/user:secret@example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false, false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open Zed links with anything beyond a plain login and host", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://ssh/-oProxyCommand=calc@example.com/home/user/project"), + electronShell.openExternal("zed://ssh/@example.com/home/user/project"), + electronShell.openExternal("zed://ssh/theo@evil@example.com/home/user/project"), + electronShell.openExternal("zed://ssh/th%20eo@example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com:2222/home/user/project"), ]); - assert.deepEqual(results, [false, false, false]); + assert.deepEqual(results, [false, false, false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 9be6d4ecf55b..c4237cb664e2 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -1,6 +1,7 @@ import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; import { REMOTE_CAPABLE_EDITOR_IDS, + REMOTE_OPEN_USER_PATTERN, remoteSchemeForEditor, type SystemSettingsPane, } from "@t3tools/contracts"; @@ -22,15 +23,27 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); -// Zed's host sits in the first path segment, so it needs its own userinfo ban. -const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; +// Zed's `[user@]host` sits in the first path segment, so it needs its own +// userinfo check: at most a login that passes REMOTE_OPEN_USER_PATTERN, the +// same `user@host` form vscode-remote authorities already take. Never a +// password or port. +const ZED_SSH_PATHNAME = /^\/(?:([^/@:]+)@)?[^/@:]+\/.*$/; + +const isZedSshPathname = (pathname: string) => { + const match = ZED_SSH_PATHNAME.exec(pathname); + if (match === null) { + return false; + } + const user = match[1]; + return user === undefined || REMOTE_OPEN_USER_PATTERN.test(user); +}; const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && (url.protocol === "zed:" - ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + ? url.host === "ssh" && isZedSshPathname(url.pathname) : url.host === "vscode-remote" && url.pathname.startsWith("/ssh-remote+") && url.pathname.length > "/ssh-remote+".length); diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 2f876b9955c1..a53a71205e59 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,10 +1,16 @@ import { it } from "@effect/vitest"; -import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import { + HostProcessHostname, + HostProcessPlatform, + HostProcessUsername, +} from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; @@ -16,27 +22,46 @@ const TAILSCALE_STATUS_JSON = JSON.stringify({ Self: { DNSName: "bb-1.tail1234.ts.net.", TailscaleIPs: ["100.64.1.2"] }, }); -/** Spawner whose `tailscale status --json` exits with the given output. */ -const spawnerLayer = (input: { readonly exitCode: number; readonly stdout: string }) => +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; +} + +// What a tailscale CLI without `tailscale get` (older than 1.102) returns. +const TAILSCALE_GET_UNSUPPORTED: CommandResult = { exitCode: 1, stdout: "" }; + +/** + * Spawner that answers `tailscale get` with `get` and every other command + * (`tailscale status --json`) with `status`, recording each subcommand it + * starts in `spawned`. + */ +const spawnerLayer = (input: { + readonly status: CommandResult; + readonly get: CommandResult; + readonly spawned: Array; +}) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.succeed( + ChildProcessSpawner.make((command) => { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + input.spawned.push(args[0] ?? ""); + const result = args[0] === "get" ? input.get : input.status; + return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), stdin: Sink.drain, - stdout: Stream.make(encoder.encode(input.stdout)), + stdout: Stream.make(encoder.encode(result.stdout)), stderr: Stream.empty, all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }), - ), - ), + ); + }), ); const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => @@ -48,22 +73,55 @@ const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => findAvailablePort: (preferred) => Effect.succeed(preferred), }); -const resolveTargets = (input: { +interface MachineInput { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; - readonly tailscale: { readonly exitCode: number; readonly stdout: string }; + readonly tailscale: CommandResult; readonly hostname: string; -}) => - Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( + /** `tailscale get --json ssh`; omitted, the CLI predates `tailscale get`. */ + readonly tailscaleSsh?: boolean; + readonly username?: string | undefined; + readonly platform?: NodeJS.Platform; + /** Collects the tailscale subcommands the resolver starts. */ + readonly spawned?: Array; +} + +/** Runs `program` against one RemoteOpenTargets built for the given machine. */ +const onMachine = ( + input: MachineInput, + program: Effect.Effect, +) => + program.pipe( Effect.provideService(HostProcessHostname, input.hostname), + Effect.provideService(HostProcessUsername, "username" in input ? input.username : "theo"), + Effect.provideService(HostProcessPlatform, input.platform ?? "linux"), Effect.provide( RemoteOpenTargets.layer.pipe( - Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + Layer.provide( + Layer.mergeAll( + netLayer(input.sshd), + spawnerLayer({ + status: input.tailscale, + get: + input.tailscaleSsh === undefined + ? TAILSCALE_GET_UNSUPPORTED + : { exitCode: 0, stdout: JSON.stringify({ ssh: input.tailscaleSsh }) }, + spawned: input.spawned ?? [], + }), + ), + ), ), ), ); +const resolveTargets = (input: MachineInput) => + onMachine( + input, + Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()), + ); + const TAILSCALE_UP = { exitCode: 0, stdout: TAILSCALE_STATUS_JSON }; const TAILSCALE_DOWN = { exitCode: 1, stdout: "" }; +const NO_SSHD = { ipv4: false, ipv6: false }; describe("RemoteOpenTargets", () => { it.effect("advertises nothing when no sshd accepts on either loopback", () => @@ -123,4 +181,113 @@ describe("RemoteOpenTargets", () => { expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local" }]); }), ); + + it.effect("advertises the tailnet name with this account when only Tailscale SSH answers", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: NO_SSHD, + tailscale: TAILSCALE_UP, + tailscaleSsh: true, + hostname: "bb-1", + }); + // No mDNS name: Tailscale SSH does not answer on the LAN address. + expect(targets).toEqual([ + { kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net", user: "theo" }, + ]); + }), + ); + + it.effect("advertises nothing without sshd when Tailscale SSH is off", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: NO_SSHD, + tailscale: TAILSCALE_UP, + tailscaleSsh: false, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + + it.effect("advertises nothing for Tailscale SSH without a MagicDNS name", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: NO_SSHD, + tailscale: TAILSCALE_DOWN, + tailscaleSsh: true, + hostname: "bb-1", + }); + expect(targets).toEqual([]); + }), + ); + + it.effect("does not start the tailscale CLI for Tailscale SSH on Windows hosts", () => + Effect.gen(function* () { + const spawned: Array = []; + const targets = yield* resolveTargets({ + sshd: NO_SSHD, + tailscale: TAILSCALE_UP, + tailscaleSsh: true, + hostname: "bb-1", + platform: "win32", + spawned, + }); + expect(targets).toEqual([]); + expect(spawned).toEqual([]); + }), + ); + + it.effect("starts the tailscale CLI at most once a minute on hosts without sshd", () => { + const spawned: Array = []; + return onMachine( + { sshd: NO_SSHD, tailscale: TAILSCALE_UP, tailscaleSsh: true, hostname: "bb-1", spawned }, + Effect.gen(function* () { + const service = yield* RemoteOpenTargets.RemoteOpenTargets; + const first = yield* service.resolveTargets(); + const second = yield* service.resolveTargets(); + expect(second).toEqual(first); + expect(spawned.toSorted()).toEqual(["get", "status"]); + + yield* TestClock.adjust(Duration.minutes(1)); + yield* service.resolveTargets(); + expect(spawned.toSorted()).toEqual(["get", "get", "status", "status"]); + }), + ).pipe(Effect.provide(TestClock.layer())); + }); + + it.effect("leaves out an account name a link cannot carry", () => + Effect.gen(function* () { + for (const username of [undefined, "-oProxyCommand=calc", "DOMAIN\\theo"]) { + const targets = yield* resolveTargets({ + sshd: NO_SSHD, + tailscale: TAILSCALE_UP, + tailscaleSsh: true, + hostname: "bb-1", + username, + }); + expect(targets).toEqual([{ kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net" }]); + } + }), + ); + + // Tailscale SSH also answers the tailnet name when sshd runs, but those + // targets predate it; they stay exactly as older servers advertised them, + // from the same single `tailscale status` call. + it.effect("keeps sshd targets unchanged when Tailscale SSH is also on", () => + Effect.gen(function* () { + const spawned: Array = []; + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: false }, + tailscale: TAILSCALE_UP, + tailscaleSsh: true, + hostname: "bb-1", + spawned, + }); + expect(targets).toEqual([ + { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, + { kind: "mdns", host: "bb-1.local" }, + ]); + expect(spawned).toEqual(["status"]); + }), + ); }); diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index c0cd658a28c7..54664066a6dd 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -7,17 +7,30 @@ * given name resolves from the viewer's machine is inherently client-side. * Targets are ordered most-reachable first (tailnet name works from anywhere * on the tailnet; `.local` only on the same LAN). + * + * Without sshd, Tailscale SSH can still serve the tailnet name. It answers + * port 22 only for tailnet peers, so the loopback probe cannot see it; the + * server asks tailscaled instead and advertises that one name. */ -import { type RemoteOpenTarget } from "@t3tools/contracts"; -import { HostProcessHostname } from "@t3tools/shared/hostProcess"; +import { type RemoteOpenTarget, RemoteOpenUser } from "@t3tools/contracts"; +import { + HostProcessHostname, + HostProcessPlatform, + HostProcessUsername, +} from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; const SSH_PORT = 22; +const TAILSCALE_SSH_CACHE_TTL = Duration.minutes(1); + +const isRemoteOpenUser = Schema.is(RemoteOpenUser); export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, @@ -31,27 +44,67 @@ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + // Tailscale absent or down is the common case, not an error. + const readMagicDnsName = readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + // Hosts without sshd would otherwise start the tailscale CLI on every + // config load. The answer only changes after `tailscale set --ssh`, and + // clients pick up targets only when they fetch config again anyway. + const resolveTailscaleSshTargets = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + // Tailscale SSH has no Windows server, so skip the CLI there. + if ((yield* HostProcessPlatform) === "win32") { + return []; + } + // Ask both at once: each tailscale CLI start can take hundreds of + // milliseconds, and config loads wait on this. + const [sshEnabled, magicDnsName] = yield* Effect.all( + [ + readTailscaleSshEnabled.pipe( + Effect.orElseSucceed(() => false), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + readMagicDnsName, + ], + { concurrency: "unbounded" }, + ); + if (!sshEnabled || magicDnsName === null) { + return []; + } + // Tailscale SSH signs in as the user the editor names, and editors + // default to the viewing machine's username. Naming this process's + // account opens the project as the user T3 Code runs as. + const user = yield* HostProcessUsername; + const target: RemoteOpenTarget = + user !== undefined && isRemoteOpenUser(user) + ? { kind: "tailscale-ssh", host: magicDnsName, user } + : { kind: "tailscale-ssh", host: magicDnsName }; + return [target]; + }), + TAILSCALE_SSH_CACHE_TTL, + ); + const resolveTargets = Effect.gen(function* () { - // No local sshd means no name can work; advertise nothing so clients - // render a clear "no SSH route" state instead of links that hang. // Check both loopback families: sshd can be bound IPv6-only. const sshdListening = yield* Effect.zipWith( net.hasListenerOnHost(SSH_PORT, "127.0.0.1"), net.hasListenerOnHost(SSH_PORT, "::1"), (ipv4, ipv6) => ipv4 || ipv6, ); + // No local sshd leaves Tailscale SSH as the only possible route; with + // neither, advertise nothing so clients render a clear "no SSH route" + // state instead of links that hang. if (!sshdListening) { - return []; + return yield* resolveTailscaleSshTargets; } const targets: Array = []; - // Tailscale absent or down is the common case, not an error. - const magicDnsName = yield* readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ); + const magicDnsName = yield* readMagicDnsName; if (magicDnsName !== null) { targets.push({ kind: "tailscale", host: magicDnsName }); } diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 3c0b2cd118ed..429ac3f11c95 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -234,6 +234,7 @@ export const OpenInPicker = memo(function OpenInPicker({ const url = buildRemoteOpenUrl({ editor, host: remote.host.host, + user: remote.host.user, absolutePath: openInCwd, }); if (url === undefined) return; @@ -314,7 +315,9 @@ export const OpenInPicker = memo(function OpenInPicker({ ))} {remote.mode === "remote-links" && !remoteHintSeen && ( - Opens over SSH. Needs your key on {environmentLabel} + {remote.host.kind === "tailscale-ssh" + ? `Opens over Tailscale SSH. Needs tailnet access to ${environmentLabel}` + : `Opens over SSH. Needs your key on ${environmentLabel}`} )} diff --git a/apps/web/src/remoteOpen.test.ts b/apps/web/src/remoteOpen.test.ts index 0f636a526cef..bbae805a94da 100644 --- a/apps/web/src/remoteOpen.test.ts +++ b/apps/web/src/remoteOpen.test.ts @@ -93,6 +93,20 @@ describe("resolveRemoteOpenState", () => { ).toEqual({ mode: "remote-links", host: { kind: "ssh-alias", host: "sol" } }); }); + it("keeps the login of a Tailscale SSH host", () => { + expect( + resolveRemoteOpenState({ + target: new RelayConnectionTarget({ environmentId, label: "sol" }), + sshAlias: null, + isDesktopRenderer: true, + remoteOpenTargets: [{ kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "theo" }], + }), + ).toEqual({ + mode: "remote-links", + host: { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "theo" }, + }); + }); + it("reports unavailable when a remote environment advertises no hosts", () => { for (const remoteOpenTargets of [[], undefined] as const) { expect( @@ -151,6 +165,19 @@ describe("buildRemoteOpenUrl", () => { ).toBe("zed://ssh/sol.tail1234.ts.net/home/theo/code/my%20repo"); }); + it("names the login before the host when one is given", () => { + const target = { host: "sol.tail1234.ts.net", user: "theo", absolutePath: "/home/theo/x" }; + expect(buildRemoteOpenUrl({ editor: "vscode", ...target })).toBe( + "vscode://vscode-remote/ssh-remote+theo@sol.tail1234.ts.net/home/theo/x", + ); + expect(buildRemoteOpenUrl({ editor: "zed", ...target })).toBe( + "zed://ssh/theo@sol.tail1234.ts.net/home/theo/x", + ); + expect(buildRemoteOpenUrl({ editor: "vscode", ...target, user: undefined })).toBe( + "vscode://vscode-remote/ssh-remote+sol.tail1234.ts.net/home/theo/x", + ); + }); + it("drops the Windows drive letter for Zed", () => { expect( buildRemoteOpenUrl({ editor: "zed", host: "sol", absolutePath: "C:\\Users\\theo" }), diff --git a/apps/web/src/remoteOpen.ts b/apps/web/src/remoteOpen.ts index 7f23d408844e..62109180b8fa 100644 --- a/apps/web/src/remoteOpen.ts +++ b/apps/web/src/remoteOpen.ts @@ -27,6 +27,8 @@ import { useEnvironmentPresentation } from "~/state/presentation"; export interface RemoteOpenHost { readonly kind: "ssh-alias" | RemoteOpenTarget["kind"]; readonly host: string; + /** Login the link names; only Tailscale SSH targets carry one. */ + readonly user?: string; } export type RemoteOpenState = diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index bac23103f0bf..33703da86208 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -143,6 +143,23 @@ running is left alone. For Antigravity's Google callback on a remote host, see [remote sign-in](./providers-antigravity.md#sign-in-from-a-remote-device). +## Open remote projects in your editor + +For a remote environment, **Open** in the chat header opens the project in VS +Code, Cursor, VSCodium, or Zed on the device you are using. The editor connects +to the host over SSH on its own, so the host must run an SSH server: OpenSSH, +or [Tailscale SSH](https://tailscale.com/kb/1193/tailscale-ssh) with Tailscale +1.102 or later. VS Code, Cursor, and VSCodium also need their remote SSH +extension. When the host has neither server, the menu shows **No SSH route**. + +With OpenSSH, the host must accept your SSH key. With Tailscale SSH, the device +running the editor must reach the host over your tailnet, and the editor signs +in as the account that runs T3 Code there. If your tailnet policy asks for a +check, editors wait without showing the sign-in link. Sign in once from a +terminal as that same account, for example +`ssh @..ts.net`, approve the check, then choose +**Open** again. + ## Manage or revoke access On the host, **Settings → Connections** lets authorized administrators create diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 88028b796991..948b3754c053 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -106,12 +106,14 @@ export const remoteSchemeForEditor = (id: EditorId): string | undefined => { /** * Builds a `://vscode-remote/ssh-remote+` deep link (Zed * takes `zed://ssh/`) that opens `absolutePath` on `host` in the - * local editor over SSH. Returns undefined for editors without remote - * deep-link support. + * local editor over SSH. A `user` becomes `user@host`; without one the editor + * picks the login. Returns undefined for editors without remote deep-link + * support. */ export const buildRemoteOpenUrl = (input: { readonly editor: EditorId; readonly host: string; + readonly user?: string | undefined; readonly absolutePath: string; }): string | undefined => { const scheme = remoteSchemeForEditor(input.editor); @@ -122,6 +124,8 @@ export const buildRemoteOpenUrl = (input: { const posixPath = input.absolutePath.replaceAll("\\", "/"); const rootedPath = posixPath.startsWith("/") ? posixPath : `/${posixPath}`; const encodedHost = encodeURIComponent(input.host); + const destination = + input.user === undefined ? encodedHost : `${encodeURIComponent(input.user)}@${encodedHost}`; if (input.editor === "zed") { // Zed's remote server resolves a rooted path on the system drive, so a // Windows `C:\Users\x` must become `/Users/x` (verified in #8938). Other @@ -129,24 +133,42 @@ export const buildRemoteOpenUrl = (input: { // POSIX path that happens to start with `/C:` is left alone. const zedPath = /^[Cc]:[\\/]/.test(input.absolutePath) ? rootedPath.slice(3) : rootedPath; const encodedZedPath = zedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://ssh/${encodedHost}${encodedZedPath}`; + return `${scheme}://ssh/${destination}${encodedZedPath}`; } const encodedPath = rootedPath.split("/").map(encodeURIComponent).join("/"); - return `${scheme}://vscode-remote/ssh-remote+${encodedHost}${encodedPath}`; + return `${scheme}://vscode-remote/ssh-remote+${destination}${encodedPath}`; }; /** * SSH hostnames an environment advertises for remote open links. Reachability - * is client-side; the server only advertises names that resolve to itself and - * gates them on a local sshd listen check. Ordered most-reachable first - * (tailnet MagicDNS name, then mDNS `.local`). + * is client-side; the server only advertises names that resolve to itself. + * `tailscale` and `mdns` names are gated on a local sshd listen check and + * ordered most-reachable first (tailnet MagicDNS name, then mDNS + * `.local`). Without sshd, a node running Tailscale SSH advertises + * its MagicDNS name as `tailscale-ssh`: Tailscale answers port 22 only for + * tailnet peers. */ -export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); +export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns", "tailscale-ssh"]); export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; +/** + * Logins a remote open link may carry: portable POSIX account names. Editors + * pass `user@host` to the local `ssh`, so a leading "-" would read as an + * option and ":" would smuggle a password. + */ +export const REMOTE_OPEN_USER_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/; + +export const RemoteOpenUser = Schema.String.check(Schema.isPattern(REMOTE_OPEN_USER_PATTERN)); + export const RemoteOpenTarget = Schema.Struct({ kind: RemoteOpenTargetKind, host: TrimmedNonEmptyString, + /** + * Account to sign in as. Only `tailscale-ssh` targets carry one: Tailscale + * SSH maps it to a local account, and editors would otherwise send the + * viewing machine's own username. + */ + user: Schema.optionalKey(RemoteOpenUser), }); export type RemoteOpenTarget = typeof RemoteOpenTarget.Type; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 9112896eb1c9..1bbac7fdcadf 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -17,6 +17,9 @@ const decodeServerProviders = Schema.decodeUnknownSync(ServerProviders); const decodeServerObservability = Schema.decodeUnknownSync(ServerObservability); const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); +const decodeRemoteOpenTargets = Schema.decodeUnknownSync( + ServerConfig.fields.remoteOpenTargets.schema, +); const baseProviderSnapshot = { instanceId: "codex", @@ -143,6 +146,25 @@ describe("server config forward compatibility", () => { expect(parsed).toEqual(["zed", "vscode"]); }); + // Builds that predate `tailscale-ssh` read such a target as an unknown kind + // and drop it, keeping their "no SSH route" state instead of failing the + // config. A login that could reach `ssh` as an option drops the same way. + it("drops remote open targets this build cannot use", () => { + const parsed = decodeRemoteOpenTargets([ + { kind: "tailscale", host: "sol.tail1234.ts.net" }, + { kind: "some-future-kind", host: "sol.example" }, + { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "theo" }, + { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "-oProxyCommand=calc" }, + { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "theo:secret" }, + { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "DOMAIN\\theo" }, + ]); + + expect(parsed).toEqual([ + { kind: "tailscale", host: "sol.tail1234.ts.net" }, + { kind: "tailscale-ssh", host: "sol.tail1234.ts.net", user: "theo" }, + ]); + }); + // A provider status this build has never seen (a new ServerProviderState, // ServerProviderAuthStatus, etc. member) previously failed the whole // `providers` array, taking every other provider down with it and, since diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c137cac9ac7a..3b5d01e471ae 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -580,8 +580,8 @@ export const ServerConfig = Schema.Struct({ availableEditors: ForwardCompatibleArray(EditorId), /** * SSH hosts this environment advertises for remote open-in-editor links. - * Absent on servers that predate the feature; empty when the machine has no - * sshd or no advertisable name. + * Absent on servers that predate the feature; empty when the machine runs + * neither sshd nor Tailscale SSH, or has no advertisable name. */ remoteOpenTargets: Schema.optionalKey(ForwardCompatibleArray(RemoteOpenTarget)), observability: ServerObservability, diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 9bd41a825044..f3b07f42e2e8 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -115,4 +115,22 @@ export const HostProcessUserId = Context.Reference( }, ); +/** + * The account this process runs as. Undefined when the OS has no name for it: + * `os.userInfo()` throws for a uid without a passwd entry, as in some + * containers. + */ +export const HostProcessUsername = Context.Reference( + "@t3tools/shared/hostProcess/HostProcessUsername", + { + defaultValue: () => { + try { + return NodeOS.userInfo().username; + } catch { + return undefined; + } + }, + }, +); + export const isHostWindows = Effect.map(HostProcessPlatform, (platform) => platform === "win32"); diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 102153f6e204..f992afa0ecdf 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -17,11 +17,13 @@ import { isTailscaleIpv4Address, parseTailscaleMagicDnsName, parseTailscaleStatus, + readTailscaleSshEnabled, readTailscaleStatus, TAILSCALE_STATUS_TIMEOUT, TailscaleCommandExitError, TailscaleCommandSpawnError, TailscaleCommandTimeoutError, + TailscaleSettingParseError, TailscaleStatusParseError, } from "./tailscale.ts"; @@ -321,6 +323,44 @@ describe("tailscale", () => { }).pipe(Effect.provide(layer)); }); + it.effect("reads the Tailscale SSH setting through tailscale get", () => + Effect.gen(function* () { + for (const enabled of [true, false]) { + const layer = mockSpawnerLayer((command, args) => { + assert.equal(command, "tailscale"); + assert.deepEqual(args, ["get", "--json", "ssh"]); + return { stdout: `{\n "ssh": ${String(enabled)}\n}\n` }; + }); + assert.equal(yield* readTailscaleSshEnabled.pipe(Effect.provide(layer)), enabled); + } + }), + ); + + it.effect("fails the Tailscale SSH read on CLIs without tailscale get", () => { + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr: 'tailscale: unknown subcommand "get"', + })); + + return Effect.gen(function* () { + const error = yield* readTailscaleSshEnabled.pipe(Effect.flip, Effect.provide(layer)); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.subcommand, "get"); + assert.equal(error.message, "tailscale get exited with code 1."); + }); + }); + + it.effect("rejects tailscale get output without a boolean ssh setting", () => + Effect.gen(function* () { + for (const stdout of ["true", "{}", '{"ssh":"true"}']) { + const layer = mockSpawnerLayer(() => ({ stdout })); + const error = yield* readTailscaleSshEnabled.pipe(Effect.flip, Effect.provide(layer)); + assert.instanceOf(error, TailscaleSettingParseError); + } + }), + ); + it.effect("configures tailscale serve through the process spawner service", () => { const layer = mockSpawnerLayer((command, args) => { assert.equal(command, "tailscale"); diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 096176aa42f8..73e5d814a8b5 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -19,7 +19,7 @@ const tailscaleCommandForPlatform = (platform: NodeJS.Platform): "tailscale" | " const TailscaleCommandContext = { executable: Schema.Literals(["tailscale", "tailscale.exe"]), - subcommand: Schema.Literals(["status", "serve"]), + subcommand: Schema.Literals(["status", "serve", "get"]), argumentCount: Schema.Number, }; @@ -126,6 +126,15 @@ export class TailscaleStatusParseError extends Schema.TaggedError()( + "TailscaleSettingParseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to decode tailscale get JSON."; + } +} + const TailscaleStatusSelf = Schema.Struct({ DNSName: Schema.optional(Schema.Unknown), TailscaleIPs: Schema.optional(Schema.Unknown), @@ -214,63 +223,89 @@ export const parseTailscaleStatus = ( }), ); -export const readTailscaleStatus = Effect.gen(function* () { - const args = ["status", "--json"]; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const hostPlatform = yield* HostProcessPlatform; - const executable = tailscaleCommandForPlatform(hostPlatform); - const commandContext = { - executable, - subcommand: "status" as const, - argumentCount: args.length, - }; - return yield* Effect.gen(function* () { - const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( - Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), - // Spawning can also fail as a defect rather than a typed error - a - // non-directory entry on PATH makes node throw ENOTDIR synchronously. - // `mapError` never sees that, so it would escape as an uncaught error. - Effect.catchDefect((cause) => - Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), - ), - ); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectStdout(child.stdout), - collectStderr(child.stderr), - child.exitCode.pipe(Effect.map(Number)), - ], - { concurrency: "unbounded" }, - ).pipe( - Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), - ); - if (exitCode !== 0) { - return yield* new TailscaleCommandExitError({ - ...commandContext, - exitCode, - stdoutLength: stdout.length, - stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), - }); - } - return yield* parseTailscaleStatus(stdout); - }).pipe( - Effect.scoped, - Effect.timeout(TAILSCALE_STATUS_TIMEOUT), - Effect.catchTags({ - TimeoutError: (cause) => - Effect.fail( - new TailscaleCommandTimeoutError({ - ...commandContext, - timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), - cause, - }), +/** Runs a read-only tailscale subcommand and returns its stdout. */ +const readTailscaleCommandOutput = (subcommand: "status" | "get", args: readonly string[]) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcessPlatform; + const executable = tailscaleCommandForPlatform(hostPlatform); + const commandContext = { + executable, + subcommand, + argumentCount: args.length, + }; + return yield* Effect.gen(function* () { + const child = yield* spawner.spawn(ChildProcess.make(executable, args)).pipe( + Effect.mapError((cause) => new TailscaleCommandSpawnError({ ...commandContext, cause })), + // Spawning can also fail as a defect rather than a typed error - a + // non-directory entry on PATH makes node throw ENOTDIR synchronously. + // `mapError` never sees that, so it would escape as an uncaught error. + Effect.catchDefect((cause) => + Effect.fail(new TailscaleCommandSpawnError({ ...commandContext, cause })), ), - }), - ); -}); + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStdout(child.stdout), + collectStderr(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), + ); + if (exitCode !== 0) { + return yield* new TailscaleCommandExitError({ + ...commandContext, + exitCode, + stdoutLength: stdout.length, + stderrLength: stderr.length, + ...(stderrDiagnosticOf(stderr) !== undefined + ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } + : {}), + }); + } + return stdout; + }).pipe( + Effect.scoped, + Effect.timeout(TAILSCALE_STATUS_TIMEOUT), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new TailscaleCommandTimeoutError({ + ...commandContext, + timeoutMs: Duration.toMillis(TAILSCALE_STATUS_TIMEOUT), + cause, + }), + ), + }), + ); + }); + +export const readTailscaleStatus = readTailscaleCommandOutput("status", ["status", "--json"]).pipe( + Effect.flatMap(parseTailscaleStatus), +); + +const decodeTailscaleSshSettingJson = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ ssh: Schema.Boolean })), +); + +/** + * Whether this node runs Tailscale SSH (`tailscale set --ssh`). `tailscale get` + * arrived in Tailscale 1.102; older CLIs fail here, which callers read as off. + */ +export const readTailscaleSshEnabled = readTailscaleCommandOutput("get", [ + "get", + "--json", + "ssh", +]).pipe( + Effect.flatMap((stdout) => + decodeTailscaleSshSettingJson(stdout).pipe( + Effect.mapError((cause) => new TailscaleSettingParseError({ cause })), + ), + ), + Effect.map((setting) => setting.ssh), +); export function buildTailscaleHttpsBaseUrl(input: { readonly magicDnsName: string; From 84a55b602018121e51d0c75af2d5ae50ccf382ab Mon Sep 17 00:00:00 2001 From: Ved Pandey Date: Fri, 25 Sep 2026 12:22:00 +0000 Subject: [PATCH 2/2] fix(server): don't reuse an interrupted Tailscale SSH discovery The one-minute cache kept whatever exit its first caller produced. When a client disconnected during discovery, the cache kept that interruption, so on hosts without sshd every config load for the next minute failed, and so did loads already waiting on the same discovery. Discovery now runs on every config load, as the sshd path already runs `tailscale status`. Clients load config when they connect, so the cache rarely saved a read. --- .../src/environment/RemoteOpenTargets.test.ts | 82 ++++++++++++------- .../src/environment/RemoteOpenTargets.ts | 71 ++++++++-------- 2 files changed, 86 insertions(+), 67 deletions(-) diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index a53a71205e59..6f93546b7f56 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -5,12 +5,12 @@ import { HostProcessUsername, } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; -import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; @@ -33,23 +33,32 @@ const TAILSCALE_GET_UNSUPPORTED: CommandResult = { exitCode: 1, stdout: "" }; /** * Spawner that answers `tailscale get` with `get` and every other command * (`tailscale status --json`) with `status`, recording each subcommand it - * starts in `spawned`. + * starts in `spawned`. With `stall`, the first command never exits and + * completes `stall` once it has started. */ const spawnerLayer = (input: { readonly status: CommandResult; readonly get: CommandResult; readonly spawned: Array; + readonly stall: Deferred.Deferred | undefined; }) => Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - const { args } = command as unknown as { readonly args: ReadonlyArray }; - input.spawned.push(args[0] ?? ""); - const result = args[0] === "get" ? input.get : input.status; - return Effect.succeed( - ChildProcessSpawner.makeHandle({ + ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const { args } = command as unknown as { readonly args: ReadonlyArray }; + input.spawned.push(args[0] ?? ""); + const result = args[0] === "get" ? input.get : input.status; + const stall = input.spawned.length === 1 ? input.stall : undefined; + if (stall !== undefined) { + yield* Deferred.succeed(stall, undefined); + } + return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), + exitCode: + stall !== undefined + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(result.exitCode)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -59,9 +68,9 @@ const spawnerLayer = (input: { all: Stream.empty, getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, - }), - ); - }), + }); + }), + ), ); const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => @@ -83,6 +92,8 @@ interface MachineInput { readonly platform?: NodeJS.Platform; /** Collects the tailscale subcommands the resolver starts. */ readonly spawned?: Array; + /** Holds the first tailscale command open; completed once it starts. */ + readonly stall?: Deferred.Deferred; } /** Runs `program` against one RemoteOpenTargets built for the given machine. */ @@ -106,6 +117,7 @@ const onMachine = ( ? TAILSCALE_GET_UNSUPPORTED : { exitCode: 0, stdout: JSON.stringify({ ssh: input.tailscaleSsh }) }, spawned: input.spawned ?? [], + stall: input.stall, }), ), ), @@ -237,23 +249,35 @@ describe("RemoteOpenTargets", () => { }), ); - it.effect("starts the tailscale CLI at most once a minute on hosts without sshd", () => { - const spawned: Array = []; - return onMachine( - { sshd: NO_SSHD, tailscale: TAILSCALE_UP, tailscaleSsh: true, hostname: "bb-1", spawned }, - Effect.gen(function* () { - const service = yield* RemoteOpenTargets.RemoteOpenTargets; - const first = yield* service.resolveTargets(); - const second = yield* service.resolveTargets(); - expect(second).toEqual(first); - expect(spawned.toSorted()).toEqual(["get", "status"]); + // A client that disconnects mid-discovery interrupts its own config load. + // The next load, from any client, must still get a real answer. + it.effect("answers the next config load after one is cut off mid-discovery", () => + Effect.gen(function* () { + const spawned: Array = []; + const stall = yield* Deferred.make(); + yield* onMachine( + { + sshd: NO_SSHD, + tailscale: TAILSCALE_UP, + tailscaleSsh: true, + hostname: "bb-1", + spawned, + stall, + }, + Effect.gen(function* () { + const service = yield* RemoteOpenTargets.RemoteOpenTargets; + const cutOff = yield* Effect.forkChild(service.resolveTargets()); + yield* Deferred.await(stall); + yield* Fiber.interrupt(cutOff); - yield* TestClock.adjust(Duration.minutes(1)); - yield* service.resolveTargets(); - expect(spawned.toSorted()).toEqual(["get", "get", "status", "status"]); - }), - ).pipe(Effect.provide(TestClock.layer())); - }); + expect(yield* service.resolveTargets()).toEqual([ + { kind: "tailscale-ssh", host: "bb-1.tail1234.ts.net", user: "theo" }, + ]); + expect(spawned.toSorted()).toEqual(["get", "get", "status", "status"]); + }), + ); + }), + ); it.effect("leaves out an account name a link cannot carry", () => Effect.gen(function* () { diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index 54664066a6dd..26bccee042c4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -21,14 +21,12 @@ import { import * as NetService from "@t3tools/shared/Net"; import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; const SSH_PORT = 22; -const TAILSCALE_SSH_CACHE_TTL = Duration.minutes(1); const isRemoteOpenUser = Schema.is(RemoteOpenUser); @@ -51,42 +49,39 @@ export const make = Effect.gen(function* () { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); - // Hosts without sshd would otherwise start the tailscale CLI on every - // config load. The answer only changes after `tailscale set --ssh`, and - // clients pick up targets only when they fetch config again anyway. - const resolveTailscaleSshTargets = yield* Effect.cachedWithTTL( - Effect.gen(function* () { - // Tailscale SSH has no Windows server, so skip the CLI there. - if ((yield* HostProcessPlatform) === "win32") { - return []; - } - // Ask both at once: each tailscale CLI start can take hundreds of - // milliseconds, and config loads wait on this. - const [sshEnabled, magicDnsName] = yield* Effect.all( - [ - readTailscaleSshEnabled.pipe( - Effect.orElseSucceed(() => false), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - readMagicDnsName, - ], - { concurrency: "unbounded" }, - ); - if (!sshEnabled || magicDnsName === null) { - return []; - } - // Tailscale SSH signs in as the user the editor names, and editors - // default to the viewing machine's username. Naming this process's - // account opens the project as the user T3 Code runs as. - const user = yield* HostProcessUsername; - const target: RemoteOpenTarget = - user !== undefined && isRemoteOpenUser(user) - ? { kind: "tailscale-ssh", host: magicDnsName, user } - : { kind: "tailscale-ssh", host: magicDnsName }; - return [target]; - }), - TAILSCALE_SSH_CACHE_TTL, - ); + // Read on every config load, as the sshd path reads `tailscale status`. + // Sharing one read across loads would hand a disconnecting client's + // interruption to every load waiting on it. + const resolveTailscaleSshTargets = Effect.gen(function* () { + // Tailscale SSH has no Windows server, so skip the CLI there. + if ((yield* HostProcessPlatform) === "win32") { + return []; + } + // Ask both at once: each tailscale CLI start can take hundreds of + // milliseconds, and config loads wait on this. + const [sshEnabled, magicDnsName] = yield* Effect.all( + [ + readTailscaleSshEnabled.pipe( + Effect.orElseSucceed(() => false), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + readMagicDnsName, + ], + { concurrency: "unbounded" }, + ); + if (!sshEnabled || magicDnsName === null) { + return []; + } + // Tailscale SSH signs in as the user the editor names, and editors + // default to the viewing machine's username. Naming this process's + // account opens the project as the user T3 Code runs as. + const user = yield* HostProcessUsername; + const target: RemoteOpenTarget = + user !== undefined && isRemoteOpenUser(user) + ? { kind: "tailscale-ssh", host: magicDnsName, user } + : { kind: "tailscale-ssh", host: magicDnsName }; + return [target]; + }); const resolveTargets = Effect.gen(function* () { // Check both loopback families: sshd can be bound IPv6-only.