From 43342249d433e8e64e26141778e57fabfb4a8958 Mon Sep 17 00:00:00 2001 From: Hauke Schnau Date: Fri, 11 Sep 2026 09:34:07 +0200 Subject: [PATCH] fix(server): let operators set the SSH host for open-in-editor links Remote open-in-editor links only ever used a host the server guessed for itself: the tailnet MagicDNS name or .local. Clients on another network cannot resolve the mDNS name, and a bare hostname cannot carry a non-default SSH port or user, so the only workaround was an ssh config alias on every client that shadows the guessed name. T3CODE_REMOTE_OPEN_HOST names the host to advertise. When set, the server advertises it alone as a target of the new kind 'configured' and skips the sshd and Tailscale probes. Pointing it at an ssh config alias lets the viewer's own ~/.ssh/config supply user, port, key and ProxyCommand. Proposed in #10326; same root cause as #10906. --- .../DesktopBackendConfiguration.test.ts | 10 ++-- .../backend/DesktopBackendConfiguration.ts | 15 ++++-- apps/server/src/cli/config.ts | 5 ++ apps/server/src/config.ts | 2 + .../src/environment/RemoteOpenTargets.test.ts | 49 ++++++++++++++++++- .../src/environment/RemoteOpenTargets.ts | 27 +++++++--- docs/user/remote-access.md | 12 +++++ packages/contracts/src/editor.ts | 11 +++-- 8 files changed, 112 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 747663b80ac0..e70db8ac5af7 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -866,10 +866,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousOpenAiKey = process.env.OPENAI_API_KEY; const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; + const previousRemoteOpenHost = process.env.T3CODE_REMOTE_OPEN_HOST; try { process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; + process.env.T3CODE_REMOTE_OPEN_HOST = "sol"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -889,13 +891,14 @@ describe("DesktopBackendConfiguration", () => { assert.equal(config.httpBaseUrl.href, "http://172.27.0.99:5050/"); assert.equal(config.env.OPENAI_API_KEY, "openai-key"); assert.equal(config.env.ANTHROPIC_API_KEY, "anthropic-key"); + assert.equal(config.env.T3CODE_REMOTE_OPEN_HOST, "sol"); // The existing WSLENV is preserved byte-for-byte (note the empty // "::" segment survives — WSL ignores it, so we don't normalize - // it away) and ANTHROPIC_API_KEY is appended. OPENAI_API_KEY is - // already declared, so it isn't forwarded twice. + // it away) and the remaining forwarded names are appended. + // OPENAI_API_KEY is already declared, so it isn't forwarded twice. assert.equal( config.env.WSLENV, - "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY", + "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u:ANTHROPIC_API_KEY:T3CODE_REMOTE_OPEN_HOST", ); }).pipe( Effect.provide( @@ -918,6 +921,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OPENAI_API_KEY", previousOpenAiKey); restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); + restoreEnv("T3CODE_REMOTE_OPEN_HOST", previousRemoteOpenHost); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d7c524d16815..c1caf1f4a182 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -87,11 +87,16 @@ const DESKTOP_BACKEND_ENV_NAMES = [ "T3CODE_TAILSCALE_SERVE_PORT", ] as const; -// Sensitive env vars that the WSL backend needs but Windows process.env won't -// forward across the wsl.exe boundary without WSLENV. The dev-server URL is -// handled separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. -const WSL_FORWARDED_ENV_NAMES = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] as const; +// Env vars that the WSL backend needs but Windows process.env won't forward +// across the wsl.exe boundary without WSLENV: provider secrets and the remote +// open-in-editor host operators set on the desktop process. The dev-server +// URL is handled separately via a `--dev-url` CLI flag because WSLENV +// translation of URL-shaped values (colons / slashes) is unreliable. +const WSL_FORWARDED_ENV_NAMES = [ + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "T3CODE_REMOTE_OPEN_HOST", +] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 847edbbc4fe0..975b95761eaa 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -139,6 +139,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + remoteOpenHost: Config.string("T3CODE_REMOTE_OPEN_HOST").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -384,6 +388,7 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + remoteOpenHost: env.remoteOpenHost?.trim() || undefined, }; return config; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1486f6b40a2a..1c278b2f157b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -90,6 +90,8 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Hostname or ssh config alias clients use for remote open-in-editor deep links. */ + readonly remoteOpenHost?: string | undefined; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts index 2f876b9955c1..850c8e825534 100644 --- a/apps/server/src/environment/RemoteOpenTargets.test.ts +++ b/apps/server/src/environment/RemoteOpenTargets.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { HostProcessHostname } from "@t3tools/shared/hostProcess"; import * as NetService from "@t3tools/shared/Net"; @@ -8,6 +9,7 @@ import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { describe, expect } from "vite-plus/test"; +import * as ServerConfig from "../config.ts"; import * as RemoteOpenTargets from "./RemoteOpenTargets.ts"; const encoder = new TextEncoder(); @@ -48,16 +50,33 @@ const netLayer = (input: { readonly ipv4: boolean; readonly ipv6: boolean }) => findAvailablePort: (preferred) => Effect.succeed(preferred), }); +/** Test server config with the remote-open alias applied on top. */ +const serverConfigLayer = (remoteOpenHost: string | undefined) => + Layer.effect( + ServerConfig.ServerConfig, + Effect.map(ServerConfig.ServerConfig, (base) => ServerConfig.make({ ...base, remoteOpenHost })), + ).pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "remote-open-targets-" })), + Layer.provide(NodeServices.layer), + ); + const resolveTargets = (input: { readonly sshd: { readonly ipv4: boolean; readonly ipv6: boolean }; readonly tailscale: { readonly exitCode: number; readonly stdout: string }; readonly hostname: string; + readonly remoteOpenHost?: string; }) => Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) => service.resolveTargets()).pipe( Effect.provideService(HostProcessHostname, input.hostname), Effect.provide( RemoteOpenTargets.layer.pipe( - Layer.provide(Layer.mergeAll(netLayer(input.sshd), spawnerLayer(input.tailscale))), + Layer.provide( + Layer.mergeAll( + netLayer(input.sshd), + spawnerLayer(input.tailscale), + serverConfigLayer(input.remoteOpenHost), + ), + ), ), ), ); @@ -77,6 +96,34 @@ describe("RemoteOpenTargets", () => { }), ); + it.effect("advertises the configured host ahead of probed names", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: true, ipv6: true }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + remoteOpenHost: "bb-1", + }); + expect(targets).toEqual([ + { kind: "configured", host: "bb-1" }, + { kind: "tailscale", host: "bb-1.tail1234.ts.net" }, + { kind: "mdns", host: "bb-1.local" }, + ]); + }), + ); + + it.effect("advertises the configured host even when nothing can be probed", () => + Effect.gen(function* () { + const targets = yield* resolveTargets({ + sshd: { ipv4: false, ipv6: false }, + tailscale: TAILSCALE_UP, + hostname: "bb-1", + remoteOpenHost: "bb-1", + }); + expect(targets).toEqual([{ kind: "configured", host: "bb-1" }]); + }), + ); + it.effect("orders the tailnet name before the mDNS name", () => Effect.gen(function* () { const targets = yield* resolveTargets({ diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index c0cd658a28c7..93f316072f31 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -2,11 +2,17 @@ * RemoteOpenTargets - resolves the SSH hostnames this environment advertises * for remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`). * - * The server can only check itself: sshd listening locally, tailscaled - * reporting a MagicDNS name, and the machine hostname for mDNS. Whether a - * 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). + * An operator-configured host (`T3CODE_REMOTE_OPEN_HOST`) comes first: an + * ssh config alias there resolves user, port and key through the viewer's + * own `~/.ssh/config`, which a bare hostname cannot express. The probed names + * still follow it so clients that predate the `configured` kind keep the + * target they had. + * + * For probing, the server can only check itself: sshd listening locally, + * tailscaled reporting a MagicDNS name, and the machine hostname for mDNS. + * Whether a 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). */ import { type RemoteOpenTarget } from "@t3tools/contracts"; import { HostProcessHostname } from "@t3tools/shared/hostProcess"; @@ -17,6 +23,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { ServerConfig } from "../config.ts"; + const SSH_PORT = 22; export class RemoteOpenTargets extends Context.Service< @@ -30,8 +38,9 @@ export class RemoteOpenTargets extends Context.Service< export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; + const { remoteOpenHost } = yield* ServerConfig; - const resolveTargets = Effect.gen(function* () { + const probeTargets = 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. @@ -67,6 +76,12 @@ export const make = Effect.gen(function* () { return targets; }); + const resolveTargets = Effect.map(probeTargets, (probed): ReadonlyArray => + remoteOpenHost === undefined + ? probed + : [{ kind: "configured", host: remoteOpenHost }, ...probed], + ); + return RemoteOpenTargets.of({ resolveTargets: () => resolveTargets }); }); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..c621bc81b3f2 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -116,6 +116,18 @@ For a plain HTTP LAN endpoint, use the direct pairing URL in a browser that can open it, or pair from the desktop app. On mobile, an IP address entered without a scheme uses HTTP, so include `https://` when your server uses HTTPS. +## Open a remote project in your editor + +When you view a server from another machine, the **Open** button in the thread +header opens the project in your local editor over SSH instead of launching an +editor on the server. + +The link uses a hostname the server advertises for itself. If that name does not +resolve from your machine, or your server uses a non-default SSH port or user, +set `T3CODE_REMOTE_OPEN_HOST` in the server's environment to a hostname or an +alias from your `~/.ssh/config`. An alias lets the editor resolve the port, user +and key from your SSH config. Your SSH key must be authorized on the server. + ## Desktop-managed SSH In the desktop app, open **Settings → Connections → Add environment**, choose diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0680a398879e..8a1c656049b1 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -135,11 +135,14 @@ export const buildRemoteOpenUrl = (input: { /** * 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. A `configured` host is operator-declared + * (`T3CODE_REMOTE_OPEN_HOST`) and comes first; as an ssh config alias it is + * the only way to carry a non-default port or user through a `vscode-remote` + * URI. Probed names follow: the server advertises names that resolve to + * itself, gated on a local sshd listen check and ordered most-reachable + * first (tailnet MagicDNS name, then mDNS `.local`). */ -export const RemoteOpenTargetKind = Schema.Literals(["tailscale", "mdns"]); +export const RemoteOpenTargetKind = Schema.Literals(["configured", "tailscale", "mdns"]); export type RemoteOpenTargetKind = typeof RemoteOpenTargetKind.Type; export const RemoteOpenTarget = Schema.Struct({