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..6f93546b7f56 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,7 +1,13 @@ 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 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"; @@ -16,26 +22,54 @@ 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`. 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(() => - 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(input.exitCode)), + exitCode: + stall !== undefined + ? Effect.never + : 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, - }), - ), + }); + }), ), ); @@ -48,22 +82,58 @@ 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; + /** Holds the first tailscale command open; completed once it starts. */ + readonly stall?: Deferred.Deferred; +} + +/** 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 ?? [], + stall: input.stall, + }), + ), + ), ), ), ); +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 +193,125 @@ 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([]); + }), + ); + + // 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); + + 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* () { + 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..26bccee042c4 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -7,18 +7,29 @@ * 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 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 isRemoteOpenUser = Schema.is(RemoteOpenUser); + export class RemoteOpenTargets extends Context.Service< RemoteOpenTargets, { @@ -31,27 +42,64 @@ 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), + ); + + // 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* () { - // 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;