From 945222fb31880eede5231ef3ca29af77673f61f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:13:05 -0700 Subject: [PATCH 01/19] feat(remote): add advertised endpoint registry Co-authored-by: codex --- .../settings/ConnectionsSettings.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 31253781930..2d3bb458f43 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -307,6 +307,28 @@ function resolveAdvertisedEndpointPairingUrl( return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); } +function selectPairingEndpoint( + endpoints: ReadonlyArray, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + return ( + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + null + ); +} + +function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return resolveHostedPairingUrl(endpoint.httpBaseUrl, credential); + } + return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); +} + function resolveCurrentOriginPairingUrl(credential: string): string { const url = new URL("/pair", window.location.href); return setPairingTokenOnUrl(url, credential).toString(); From 961776db6bbf2ba6d250f31013791cbc650ed170 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:15:22 -0700 Subject: [PATCH 02/19] feat(desktop): add tailscale endpoint addon Co-authored-by: codex --- apps/desktop/src/main.ts | 10 +- .../src/tailscaleEndpointProvider.test.ts | 86 ++++++++++ apps/desktop/src/tailscaleEndpointProvider.ts | 155 ++++++++++++++++++ 3 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/tailscaleEndpointProvider.test.ts create mode 100644 apps/desktop/src/tailscaleEndpointProvider.ts diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c5713d2035b..9d4888b5811 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -79,6 +79,7 @@ import { import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts"; import { resolveDesktopAppBranding } from "./appBranding.ts"; import { bindFirstRevealTrigger, type RevealSubscription } from "./windowReveal.ts"; +import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts"; syncShellEnvironment(); @@ -313,18 +314,23 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } -function getDesktopAdvertisedEndpoints() { +async function getDesktopAdvertisedEndpoints() { const exposure = resolveDesktopServerExposure({ mode: desktopServerExposureMode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), }); - return resolveDesktopCoreAdvertisedEndpoints({ + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: backendPort, exposure, customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), }); + const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + }); + return [...coreEndpoints, ...tailscaleEndpoints]; } function getDesktopSecretStorage() { diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts new file mode 100644 index 00000000000..e3fe3b88204 --- /dev/null +++ b/apps/desktop/src/tailscaleEndpointProvider.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + isTailscaleIpv4Address, + parseTailscaleMagicDnsName, + resolveTailscaleAdvertisedEndpoints, +} from "./tailscaleEndpointProvider.ts"; + +describe("tailscale endpoint provider", () => { + it("detects Tailnet IPv4 addresses", () => { + expect(isTailscaleIpv4Address("100.64.0.1")).toBe(true); + expect(isTailscaleIpv4Address("100.127.255.254")).toBe(true); + expect(isTailscaleIpv4Address("100.128.0.1")).toBe(false); + expect(isTailscaleIpv4Address("192.168.1.44")).toBe(false); + }); + + it("parses MagicDNS names from tailscale status", () => { + expect( + parseTailscaleMagicDnsName(JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } })), + ).toBe("desktop.tail.ts.net"); + expect(parseTailscaleMagicDnsName("{}")).toBeNull(); + expect(parseTailscaleMagicDnsName("not-json")).toBeNull(); + }); + + it("resolves Tailscale endpoints as add-on advertised endpoints", async () => { + await expect( + resolveTailscaleAdvertisedEndpoints({ + port: 3773, + networkInterfaces: { + tailscale0: [ + { + address: "100.100.100.100", + family: "IPv4", + internal: false, + netmask: "255.192.0.0", + cidr: "100.100.100.100/10", + mac: "00:00:00:00:00:00", + }, + ], + }, + statusJson: JSON.stringify({ Self: { DNSName: "desktop.tail.ts.net." } }), + }), + ).resolves.toEqual([ + { + id: "tailscale-ip:http://100.100.100.100:3773", + label: "Tailscale IP", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "http://100.100.100.100:3773/", + wsBaseUrl: "ws://100.100.100.100:3773/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }, + { + id: "tailscale-magicdns:https://desktop.tail.ts.net", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net/", + wsBaseUrl: "wss://desktop.tail.ts.net/", + reachability: "private-network", + compatibility: { + hostedHttpsApp: "requires-configuration", + desktopApp: "compatible", + }, + source: "desktop-addon", + status: "unknown", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }, + ]); + }); +}); diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts new file mode 100644 index 00000000000..e92e8bee772 --- /dev/null +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -0,0 +1,155 @@ +import * as ChildProcess from "node:child_process"; +import type { NetworkInterfaceInfo } from "node:os"; + +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts"; + +const TAILSCALE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, +}; + +const TAILSCALE_STATUS_TIMEOUT_MS = 1_500; + +interface TailscaleStatusSelf { + readonly DNSName?: unknown; + readonly TailscaleIPs?: unknown; +} + +interface TailscaleStatusJson { + readonly Self?: TailscaleStatusSelf; +} + +function createTailscaleEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: TAILSCALE_ENDPOINT_PROVIDER, + source: "desktop-addon", + }); +} + +export function isTailscaleIpv4Address(address: string): boolean { + const parts = address.split("."); + if (parts.length !== 4) { + return false; + } + const [first, second, third, fourth] = parts.map((part) => Number.parseInt(part, 10)); + if ( + first === undefined || + second === undefined || + third === undefined || + fourth === undefined || + [first, second, third, fourth].some((part) => !Number.isInteger(part) || part < 0 || part > 255) + ) { + return false; + } + return first === 100 && second >= 64 && second <= 127; +} + +export function resolveTailscaleIpAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; +}): readonly AdvertisedEndpoint[] { + const seen = new Set(); + const endpoints: AdvertisedEndpoint[] = []; + + for (const interfaceAddresses of Object.values(input.networkInterfaces)) { + if (!interfaceAddresses) continue; + + for (const address of interfaceAddresses) { + if (address.internal) continue; + if (address.family !== "IPv4") continue; + if (!isTailscaleIpv4Address(address.address)) continue; + if (seen.has(address.address)) continue; + seen.add(address.address); + + endpoints.push( + createTailscaleEndpoint({ + id: `tailscale-ip:http://${address.address}:${input.port}`, + label: "Tailscale IP", + httpBaseUrl: `http://${address.address}:${input.port}`, + reachability: "private-network", + status: "available", + description: "Reachable from devices on the same Tailnet.", + }), + ); + } + } + + return endpoints; +} + +export function parseTailscaleMagicDnsName(rawStatusJson: string): string | null { + let parsed: TailscaleStatusJson; + try { + parsed = JSON.parse(rawStatusJson) as TailscaleStatusJson; + } catch { + return null; + } + + const dnsName = parsed.Self?.DNSName; + if (typeof dnsName !== "string") { + return null; + } + + const normalized = dnsName.trim().replace(/\.$/u, ""); + return normalized.length > 0 ? normalized : null; +} + +export function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { + readonly dnsName: string | null; +}): AdvertisedEndpoint | null { + if (!input.dnsName) { + return null; + } + + return createTailscaleEndpoint({ + id: `tailscale-magicdns:https://${input.dnsName}`, + label: "Tailscale HTTPS", + httpBaseUrl: `https://${input.dnsName}`, + reachability: "private-network", + hostedHttpsCompatibility: "requires-configuration", + status: "unknown", + description: "MagicDNS hostname. Configure Tailscale Serve for HTTPS access.", + }); +} + +async function readTailscaleStatusJson(): Promise { + return await new Promise((resolve) => { + const child = ChildProcess.execFile( + "tailscale", + ["status", "--json"], + { timeout: TAILSCALE_STATUS_TIMEOUT_MS, windowsHide: true }, + (error, stdout) => { + if (error) { + resolve(null); + return; + } + resolve(stdout); + }, + ); + child.once("error", () => resolve(null)); + }); +} + +export async function resolveTailscaleAdvertisedEndpoints(input: { + readonly port: number; + readonly networkInterfaces: NodeJS.Dict; + readonly statusJson?: string | null; +}): Promise { + const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints(input); + const statusJson = + input.statusJson === undefined ? await readTailscaleStatusJson() : input.statusJson; + const magicDnsEndpoint = resolveTailscaleMagicDnsAdvertisedEndpoint({ + dnsName: statusJson ? parseTailscaleMagicDnsName(statusJson) : null, + }); + + return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; +} From 9ba22ec51b52277bee5f17e9b8554ad84327d23e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:48:32 -0700 Subject: [PATCH 03/19] fix(web): dedupe advertised pairing helpers Co-authored-by: codex --- .../settings/ConnectionsSettings.tsx | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 2d3bb458f43..31253781930 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -307,28 +307,6 @@ function resolveAdvertisedEndpointPairingUrl( return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); } -function selectPairingEndpoint( - endpoints: ReadonlyArray, -): AdvertisedEndpoint | null { - const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); - return ( - availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? - availableEndpoints.find((endpoint) => endpoint.isDefault) ?? - availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? - null - ); -} - -function resolveAdvertisedEndpointPairingUrl( - endpoint: AdvertisedEndpoint, - credential: string, -): string { - if (endpoint.compatibility.hostedHttpsApp === "compatible") { - return resolveHostedPairingUrl(endpoint.httpBaseUrl, credential); - } - return resolveDesktopPairingUrl(endpoint.httpBaseUrl, credential); -} - function resolveCurrentOriginPairingUrl(credential: string): string { const url = new URL("/pair", window.location.href); return setPairingTokenOnUrl(url, credential).toString(); From 1ac678f9a4ffc061ee3bc812ef40ffc8741bba43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:56:39 -0700 Subject: [PATCH 04/19] docs(remote): document tailscale endpoint add-on Co-authored-by: codex --- .docs/remote-architecture.md | 17 +++++++++++++++++ REMOTE.md | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 1748df5db65..89b3d41ea0c 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -136,6 +136,21 @@ When no user default is saved, endpoint selection should prefer: This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. +### Endpoint providers + +Endpoint providers are add-ons that contribute advertised endpoints for the current environment. + +The provider boundary is intentionally outside the core environment model: + +- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle +- providers discover or synthesize endpoints +- providers return normalized `AdvertisedEndpoint` records +- the UI and pairing logic select from those records without knowing provider-specific commands + +The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path. + +Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected. + ### Hosted pairing request A hosted pairing request is a bootstrap URL for the static web app, not a transport. @@ -220,6 +235,8 @@ This is especially useful when: - mobile must reach a desktop-hosted environment - a machine should be reachable without exposing raw LAN or public ports +Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection. + ### 3. Desktop-managed SSH access SSH is an access and launch helper, not a separate environment type. diff --git a/REMOTE.md b/REMOTE.md index f5ddccaa85a..afe22bd6040 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -35,6 +35,20 @@ When no user default is saved, the app chooses the best reachable endpoint for p If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. +### Tailscale Endpoints + +When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. + +Depending on your Tailscale setup, this may include: + +- the machine's `100.x.y.z` Tailnet IP +- a MagicDNS name +- an HTTPS MagicDNS endpoint when Tailscale HTTPS is available for the machine + +The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow. + +For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules. + ### Option 2: Headless Server (CLI) Use this when you want to run the server without a GUI, for example on a remote machine over SSH. From f74ec043674a98f51ebfffd8ed4ee3888b3ce1bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 21:38:19 -0700 Subject: [PATCH 05/19] fix(desktop): include port in tailscale https endpoint Co-authored-by: codex --- apps/desktop/src/tailscaleEndpointProvider.test.ts | 6 +++--- apps/desktop/src/tailscaleEndpointProvider.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts index e3fe3b88204..d228a2c3bf1 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.test.ts +++ b/apps/desktop/src/tailscaleEndpointProvider.test.ts @@ -62,7 +62,7 @@ describe("tailscale endpoint provider", () => { description: "Reachable from devices on the same Tailnet.", }, { - id: "tailscale-magicdns:https://desktop.tail.ts.net", + id: "tailscale-magicdns:https://desktop.tail.ts.net:3773", label: "Tailscale HTTPS", provider: { id: "tailscale", @@ -70,8 +70,8 @@ describe("tailscale endpoint provider", () => { kind: "private-network", isAddon: true, }, - httpBaseUrl: "https://desktop.tail.ts.net/", - wsBaseUrl: "wss://desktop.tail.ts.net/", + httpBaseUrl: "https://desktop.tail.ts.net:3773/", + wsBaseUrl: "wss://desktop.tail.ts.net:3773/", reachability: "private-network", compatibility: { hostedHttpsApp: "requires-configuration", diff --git a/apps/desktop/src/tailscaleEndpointProvider.ts b/apps/desktop/src/tailscaleEndpointProvider.ts index e92e8bee772..75e3096d6b2 100644 --- a/apps/desktop/src/tailscaleEndpointProvider.ts +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -105,15 +105,17 @@ export function parseTailscaleMagicDnsName(rawStatusJson: string): string | null export function resolveTailscaleMagicDnsAdvertisedEndpoint(input: { readonly dnsName: string | null; + readonly port: number; }): AdvertisedEndpoint | null { if (!input.dnsName) { return null; } + const httpBaseUrl = `https://${input.dnsName}:${input.port}`; return createTailscaleEndpoint({ - id: `tailscale-magicdns:https://${input.dnsName}`, + id: `tailscale-magicdns:${httpBaseUrl}`, label: "Tailscale HTTPS", - httpBaseUrl: `https://${input.dnsName}`, + httpBaseUrl, reachability: "private-network", hostedHttpsCompatibility: "requires-configuration", status: "unknown", @@ -149,6 +151,7 @@ export async function resolveTailscaleAdvertisedEndpoints(input: { input.statusJson === undefined ? await readTailscaleStatusJson() : input.statusJson; const magicDnsEndpoint = resolveTailscaleMagicDnsAdvertisedEndpoint({ dnsName: statusJson ? parseTailscaleMagicDnsName(statusJson) : null, + port: input.port, }); return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; From 955f6ea59932c12236ff3d80680e033a2cd108e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 14 Apr 2026 14:46:21 -0700 Subject: [PATCH 06/19] Add remote SSH environment launch support - Discover SSH hosts and persist SSH targets - Bootstrap tunneled SSH sessions with desktop password prompts - Extend IPC and storage tests for SSH metadata --- apps/desktop/src/clientPersistence.test.ts | 6 + apps/desktop/src/clientPersistence.ts | 15 +- apps/desktop/src/main.ts | 297 +++++ apps/desktop/src/preload.ts | 32 + apps/desktop/src/sshEnvironment.test.ts | 196 ++++ apps/desktop/src/sshEnvironment.ts | 1013 +++++++++++++++++ apps/web/src/clientPersistenceStorage.test.ts | 6 + apps/web/src/clientPersistenceStorage.ts | 17 +- .../desktop/SshPasswordPromptDialog.tsx | 114 ++ .../settings/ConnectionsSettings.tsx | 567 ++++++++- .../settings/SettingsPanels.browser.tsx | 113 ++ apps/web/src/components/ui/dialog.tsx | 4 +- apps/web/src/environments/remote/api.ts | 6 + apps/web/src/environments/runtime/catalog.ts | 2 + apps/web/src/environments/runtime/index.ts | 1 + .../service.addSavedEnvironment.test.ts | 106 +- apps/web/src/environments/runtime/service.ts | 469 ++++++-- apps/web/src/localApi.test.ts | 18 + apps/web/src/routes/__root.tsx | 2 + packages/contracts/src/ipc.ts | 49 +- 20 files changed, 2863 insertions(+), 170 deletions(-) create mode 100644 apps/desktop/src/sshEnvironment.test.ts create mode 100644 apps/desktop/src/sshEnvironment.ts create mode 100644 apps/web/src/components/desktop/SshPasswordPromptDialog.tsx diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac1064..41994550ae1 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -70,6 +70,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { wsBaseUrl: "wss://remote.example.com/", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: "2026-04-09T01:00:00.000Z", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, }; describe("clientPersistence", () => { diff --git a/apps/desktop/src/clientPersistence.ts b/apps/desktop/src/clientPersistence.ts index ad08a0036f1..09a3494dbc7 100644 --- a/apps/desktop/src/clientPersistence.ts +++ b/apps/desktop/src/clientPersistence.ts @@ -57,6 +57,12 @@ function isPersistedSavedEnvironmentStorageRecord( typeof value.wsBaseUrl === "string" && typeof value.createdAt === "string" && (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") && + (value.desktopSsh === undefined || + (Predicate.isObject(value.desktopSsh) && + typeof value.desktopSsh.alias === "string" && + typeof value.desktopSsh.hostname === "string" && + (value.desktopSsh.username === null || typeof value.desktopSsh.username === "string") && + (value.desktopSsh.port === null || typeof value.desktopSsh.port === "number"))) && (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string") ); } @@ -77,7 +83,7 @@ function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmen function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentStorageRecord, ): PersistedSavedEnvironmentRecord { - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -85,6 +91,7 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } export function readClientSettings(settingsPath: string): ClientSettings | null { @@ -134,6 +141,7 @@ export function writeSavedEnvironmentRegistry( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), encryptedBearerToken, } : record; @@ -189,7 +197,7 @@ export function writeSavedEnvironmentSecret(input: { const encryptedBearerToken = input.secretStorage .encryptString(input.secret) .toString("base64"); - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -197,7 +205,8 @@ export function writeSavedEnvironmentSecret(input: { createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, encryptedBearerToken, - } satisfies PersistedSavedEnvironmentStorageRecord; + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; }), } satisfies SavedEnvironmentRegistryDocument); return found; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9d4888b5811..30e811ed746 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,7 +20,13 @@ import { } from "electron"; import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, ClientSettings, + DesktopSshPasswordPromptRequest, + ExecutionEnvironmentDescriptor, + DesktopSshEnvironmentTarget, DesktopTheme, DesktopAppBranding, DesktopServerExposureMode, @@ -59,6 +65,7 @@ import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, } from "./serverExposure.ts"; +import { DesktopSshEnvironmentManager } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -104,6 +111,14 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; @@ -162,6 +177,7 @@ function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { } const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +const SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; @@ -206,6 +222,12 @@ type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; }; +interface PendingSshPasswordPrompt { + readonly resolve: (password: string | null) => void; + readonly reject: (error: Error) => void; + readonly timeout: ReturnType; +} + let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; @@ -229,6 +251,7 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; +const pendingSshPasswordPrompts = new Map(); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -405,6 +428,7 @@ function relaunchDesktopApp(reason: string): void { `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) + .then(() => desktopSshEnvironmentManager.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { @@ -467,6 +491,128 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { + if (typeof rawTarget !== "object" || rawTarget === null) { + return null; + } + + const target = rawTarget as Partial; + if (typeof target.alias !== "string" || typeof target.hostname !== "string") { + return null; + } + if ( + target.username !== null && + target.username !== undefined && + typeof target.username !== "string" + ) { + return null; + } + if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { + return null; + } + + return { + alias: target.alias.trim(), + hostname: target.hostname.trim(), + username: target.username?.trim() || null, + port: target.port ?? null, + }; +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; +} + +function resolveLoopbackSshHttpUrl(rawHttpBaseUrl: unknown, pathname: string): URL { + if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { + throw new Error("Invalid SSH forwarded http base URL."); + } + + let baseUrl: URL; + try { + baseUrl = new URL(rawHttpBaseUrl); + } catch { + throw new Error("Invalid SSH forwarded http base URL."); + } + + if (!isLoopbackHostname(baseUrl.hostname)) { + throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); + } + + const url = new URL(baseUrl.toString()); + url.pathname = pathname; + url.search = ""; + url.hash = ""; + return url; +} + +async function readRemoteFetchErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const text = await response.text(); + if (!text) { + return fallbackMessage; + } + + try { + const parsed = JSON.parse(text) as { readonly error?: string }; + if (typeof parsed.error === "string" && parsed.error.trim().length > 0) { + return parsed.error; + } + } catch { + // Fall back to the raw text below. + } + + return text; +} + +async function fetchLoopbackSshJson(input: { + readonly httpBaseUrl: unknown; + readonly pathname: string; + readonly method?: "GET" | "POST"; + readonly bearerToken?: unknown; + readonly body?: unknown; +}): Promise { + const requestUrl = resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname).toString(); + const bearerToken = + typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0 + ? input.bearerToken + : null; + + let response: Response; + try { + response = await fetch(requestUrl, { + method: input.method ?? "GET", + headers: { + ...(input.body !== undefined ? { "content-type": "application/json" } : {}), + ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}), + }, + ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), + }); + } catch (error) { + throw new Error( + `Failed to reach SSH forwarded endpoint ${requestUrl} (${error instanceof Error ? error.message : String(error)}).`, + { cause: error }, + ); + } + + if (!response.ok) { + throw new Error( + await readRemoteFetchErrorMessage( + response, + `SSH forwarded request failed (${response.status}).`, + ), + ); + } + + return (await response.json()) as T; +} + async function waitForBackendHttpReady( baseUrl: string, options?: Parameters[1], @@ -661,6 +807,56 @@ let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +function rejectPendingSshPasswordPrompts(message: string): void { + for (const [requestId, pending] of pendingSshPasswordPrompts) { + clearTimeout(pending.timeout); + pendingSshPasswordPrompts.delete(requestId); + pending.reject(new Error(message)); + } +} + +async function requestSshPasswordFromRenderer(input: { + readonly destination: string; + readonly username: string | null; + readonly prompt: string; +}): Promise { + const window = mainWindow; + if (!window || window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + + const request: DesktopSshPasswordPromptRequest = { + requestId: Crypto.randomUUID(), + destination: input.destination, + username: input.username, + prompt: input.prompt, + }; + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingSshPasswordPrompts.delete(request.requestId); + reject(new Error(`SSH authentication timed out for ${input.destination}.`)); + }, SSH_PASSWORD_PROMPT_TIMEOUT_MS); + timeout.unref(); + + pendingSshPasswordPrompts.set(request.requestId, { + resolve, + reject, + timeout, + }); + + window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); + if (window.isMinimized()) { + window.restore(); + } + window.focus(); + }); +} + +const desktopSshEnvironmentManager = new DesktopSshEnvironmentManager({ + passwordProvider: requestSshPasswordFromRenderer, +}); + function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; if (updateDownloadInFlight) return "download"; @@ -1679,6 +1875,101 @@ function registerIpcHandlers(): void { }, ); + ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); + ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => + desktopSshEnvironmentManager.discoverHosts(), + ); + + ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); + ipcMain.handle( + ENSURE_SSH_ENVIRONMENT_CHANNEL, + async (_event, rawTarget: unknown, rawOptions: unknown) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + const issuePairingToken = + typeof rawOptions === "object" && + rawOptions !== null && + "issuePairingToken" in rawOptions && + (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; + + return await desktopSshEnvironmentManager.ensureEnvironment(target, { + issuePairingToken, + }); + }, + ); + + ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); + ipcMain.handle( + FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, + async (_event, rawHttpBaseUrl: unknown) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/.well-known/t3/environment", + }), + ); + + ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); + ipcMain.handle( + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + async (_event, rawHttpBaseUrl: unknown, rawCredential: unknown) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { + credential: rawCredential, + }, + }), + ); + + ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); + ipcMain.handle( + FETCH_SSH_SESSION_STATE_CHANNEL, + async (_event, rawHttpBaseUrl: unknown, rawBearerToken: unknown) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/session", + bearerToken: rawBearerToken, + }), + ); + + ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); + ipcMain.handle( + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + async (_event, rawHttpBaseUrl: unknown, rawBearerToken: unknown) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken: rawBearerToken, + }), + ); + + ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); + ipcMain.handle( + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + async (_event, rawRequestId: unknown, rawPassword: unknown) => { + if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { + throw new Error("Invalid SSH password prompt id."); + } + if (rawPassword !== null && typeof rawPassword !== "string") { + throw new Error("Invalid SSH password prompt response."); + } + + const pending = pendingSshPasswordPrompts.get(rawRequestId); + if (!pending) { + throw new Error("SSH password prompt is no longer pending."); + } + + clearTimeout(pending.timeout); + pendingSshPasswordPrompts.delete(rawRequestId); + pending.resolve(rawPassword); + }, + ); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); @@ -2054,6 +2345,9 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + rejectPendingSshPasswordPrompts( + "SSH authentication was cancelled because the app window closed.", + ); if (mainWindow === window) { mainWindow = null; } @@ -2145,6 +2439,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentManager.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); @@ -2194,6 +2489,7 @@ if (process.platform !== "win32") { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentManager.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); @@ -2204,6 +2500,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGTERM received"); clearUpdatePollTimer(); stopBackend(); + void desktopSshEnvironmentManager.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e918e782ab9..fdfaf20813f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -22,6 +22,14 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; @@ -52,6 +60,30 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), + ensureSshEnvironment: (target, options) => + ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), + fetchSshEnvironmentDescriptor: (httpBaseUrl) => + ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), + bootstrapSshBearerSession: (httpBaseUrl, credential) => + ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), + fetchSshSessionState: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), + issueSshWebSocketToken: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), + onSshPasswordPrompt: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + + ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + }; + }, + resolveSshPasswordPrompt: (requestId, password) => + ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts new file mode 100644 index 00000000000..fa5bca6a0db --- /dev/null +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -0,0 +1,196 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { __test, discoverDesktopSshHosts } from "./sshEnvironment"; + +const tempDirectories: string[] = []; + +afterEach(() => { + for (const directory of tempDirectories.splice(0)) { + FS.rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTempHomeDir(): string { + const directory = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3-ssh-env-test-")); + tempDirectories.push(directory); + return directory; +} + +describe("sshEnvironment", () => { + it("discovers ssh config hosts across included files", async () => { + const homeDir = makeTempHomeDir(); + const sshDir = Path.join(homeDir, ".ssh"); + FS.mkdirSync(Path.join(sshDir, "config.d"), { recursive: true }); + FS.writeFileSync( + Path.join(sshDir, "config"), + ["Host devbox", " HostName devbox.example.com", "Include config.d/*.conf", ""].join("\n"), + "utf8", + ); + FS.writeFileSync( + Path.join(sshDir, "config.d", "team.conf"), + [ + "Host staging", + " HostName staging.example.com", + "Host *", + " ServerAliveInterval 30", + "", + ].join("\n"), + "utf8", + ); + FS.writeFileSync( + Path.join(sshDir, "known_hosts"), + [ + "known.example.com ssh-ed25519 AAAA", + "|1|hashed|entry ssh-ed25519 AAAA", + "[bastion.example.com]:2222 ssh-ed25519 AAAA", + "", + ].join("\n"), + "utf8", + ); + + await expect(discoverDesktopSshHosts({ homeDir })).resolves.toEqual([ + { + alias: "bastion.example.com", + hostname: "bastion.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + source: "ssh-config", + }, + { + alias: "known.example.com", + hostname: "known.example.com", + username: null, + port: null, + source: "known-hosts", + }, + { + alias: "staging", + hostname: "staging", + username: null, + port: null, + source: "ssh-config", + }, + ]); + }); + + it("parses known_hosts entries without returning hashed hosts", () => { + expect( + __test.parseKnownHostsHostnames( + [ + "github.com ssh-ed25519 AAAA", + "gitlab.com,gitlab-alias ssh-ed25519 BBBB", + "|1|hashed|entry ssh-ed25519 CCCC", + "@cert-authority *.example.com ssh-ed25519 DDDD", + "[ssh.example.com]:2200 ssh-ed25519 EEEE", + "", + ].join("\n"), + ), + ).toEqual(["github.com", "gitlab-alias", "gitlab.com", "ssh.example.com"]); + }); + + it("parses resolved ssh config output into a target", () => { + expect( + __test.parseSshResolveOutput( + "devbox", + ["hostname devbox.example.com", "user julius", "port 2222", ""].join("\n"), + ), + ).toEqual({ + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }); + }); + + it("builds interactive ssh args without forcing batch mode", () => { + expect( + __test.baseSshArgs( + { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + { batchMode: "no" }, + ), + ).toEqual(["-o", "BatchMode=no", "-o", "ConnectTimeout=10", "-p", "2222"]); + }); + + it("creates askpass env for desktop ssh prompts", () => { + const askpassDirectory = Path.join(makeTempHomeDir(), "askpass"); + const env = __test.buildSshChildEnvironment({ + authSecret: "super-secret", + interactiveAuth: true, + askpassDirectory, + platform: "linux", + baseEnv: {}, + }); + + expect(env.SSH_ASKPASS).toBe(Path.join(askpassDirectory, "ssh-askpass.sh")); + expect(env.SSH_ASKPASS_REQUIRE).toBe("force"); + expect(env.T3_SSH_AUTH_SECRET).toBe("super-secret"); + expect(env.DISPLAY).toBe("t3code"); + expect(FS.existsSync(Path.join(askpassDirectory, "ssh-askpass.sh"))).toBe(true); + expect(FS.readFileSync(Path.join(askpassDirectory, "ssh-askpass.sh"), "utf8")).toContain( + 'printf "%s\\n" "$T3_SSH_AUTH_SECRET"', + ); + }); + + it("builds a windows askpass launcher pair", () => { + const descriptor = __test.buildSshAskpassHelperDescriptor({ + directory: "C:\\temp\\t3code-ssh-askpass", + platform: "win32", + }); + + expect(descriptor.launcherPath).toBe("C:\\temp\\t3code-ssh-askpass\\ssh-askpass.cmd"); + expect(descriptor.files.map((file) => Path.win32.basename(file.path))).toEqual([ + "ssh-askpass.cmd", + "ssh-askpass.ps1", + ]); + }); + + it("builds a remote t3 runner with npx and npm fallbacks", () => { + const script = __test.buildRemoteT3RunnerScript(); + + expect(script).toContain('exec t3 "$@"'); + expect(script).toContain('exec npx --yes t3 "$@"'); + expect(script).toContain('exec npm exec --yes t3 -- "$@"'); + expect(script).toContain("could not find npx or npm on PATH"); + }); + + it("uses the remote t3 runner for launch and pairing scripts", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + expect(__test.buildRemoteLaunchScript()).toContain('"$RUNNER_FILE" serve --host 127.0.0.1'); + expect(__test.buildRemotePairingScript(target)).toContain( + '"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json', + ); + }); + + it("detects ssh auth failures from common permission denied messages", () => { + expect( + __test.isSshAuthFailure( + new Error( + "julius@100.65.180.100: Permission denied (publickey,password,keyboard-interactive).", + ), + ), + ).toBe(true); + expect(__test.isSshAuthFailure(new Error("Connection timed out"))).toBe(false); + }); +}); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts new file mode 100644 index 00000000000..e197645fd99 --- /dev/null +++ b/apps/desktop/src/sshEnvironment.ts @@ -0,0 +1,1013 @@ +import * as ChildProcess from "node:child_process"; +import * as Crypto from "node:crypto"; +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; +import * as Net from "node:net"; + +import type { + DesktopDiscoveredSshHost, + DesktopSshEnvironmentBootstrap, + DesktopSshEnvironmentTarget, +} from "@t3tools/contracts"; + +import { waitForHttpReady } from "./backendReadiness"; + +const DEFAULT_REMOTE_PORT = 3773; +const REMOTE_PORT_SCAN_WINDOW = 200; +const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; +const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; +const SSH_READY_TIMEOUT_MS = 20_000; + +interface SshTunnelEntry { + readonly key: string; + readonly target: DesktopSshEnvironmentTarget; + readonly remotePort: number; + readonly localPort: number; + readonly httpBaseUrl: string; + readonly wsBaseUrl: string; + readonly process: ChildProcess.ChildProcess; +} + +interface SshCommandResult { + readonly stdout: string; + readonly stderr: string; +} + +interface SshAskpassFile { + readonly path: string; + readonly contents: string; + readonly mode?: number; +} + +interface SshAskpassHelperDescriptor { + readonly launcherPath: string; + readonly files: ReadonlyArray; +} + +interface SshAuthOptions { + readonly authSecret?: string | null; + readonly batchMode?: "yes" | "no"; + readonly interactiveAuth?: boolean; +} + +interface DesktopSshPasswordRequest { + readonly destination: string; + readonly username: string | null; + readonly prompt: string; + readonly attempt: number; +} + +interface DesktopSshEnvironmentManagerOptions { + readonly passwordProvider?: (request: DesktopSshPasswordRequest) => Promise; +} + +const NO_HOSTS = [] as const; + +function stripInlineComment(line: string): string { + const hashIndex = line.indexOf("#"); + return (hashIndex >= 0 ? line.slice(0, hashIndex) : line).trim(); +} + +function splitDirectiveArgs(value: string): ReadonlyArray { + return value + .trim() + .split(/\s+/u) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function hasSshPattern(value: string): boolean { + return value.includes("*") || value.includes("?") || value.startsWith("!"); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function globToRegExp(pattern: string): RegExp { + return new RegExp( + `^${escapeRegex(pattern).replace(/\\\*/gu, ".*").replace(/\\\?/gu, ".")}$`, + "u", + ); +} + +function expandGlob(pattern: string): ReadonlyArray { + if (!pattern.includes("*") && !pattern.includes("?")) { + return FS.existsSync(pattern) ? [pattern] : NO_HOSTS; + } + + const directory = Path.dirname(pattern); + const basePattern = Path.basename(pattern); + if (!FS.existsSync(directory)) { + return NO_HOSTS; + } + + const matcher = globToRegExp(basePattern); + return FS.readdirSync(directory) + .filter((entry) => matcher.test(entry)) + .map((entry) => Path.join(directory, entry)) + .filter((entry) => FS.existsSync(entry)) + .toSorted((left, right) => left.localeCompare(right)); +} + +function collectSshConfigAliasesFromFile( + filePath: string, + visited = new Set(), +): ReadonlyArray { + const resolvedPath = Path.resolve(filePath); + if (visited.has(resolvedPath) || !FS.existsSync(resolvedPath)) { + return NO_HOSTS; + } + visited.add(resolvedPath); + + const aliases = new Set(); + const directory = Path.dirname(resolvedPath); + const raw = FS.readFileSync(resolvedPath, "utf8"); + + for (const line of raw.split(/\r?\n/u)) { + const stripped = stripInlineComment(line); + if (stripped.length === 0) { + continue; + } + + const [directive = "", ...rawArgs] = splitDirectiveArgs(stripped); + const normalizedDirective = directive.toLowerCase(); + if (normalizedDirective === "include") { + for (const includePattern of rawArgs) { + const resolvedPattern = Path.isAbsolute(includePattern) + ? includePattern + : Path.resolve(directory, includePattern); + for (const includedPath of expandGlob(resolvedPattern)) { + for (const alias of collectSshConfigAliasesFromFile(includedPath, visited)) { + aliases.add(alias); + } + } + } + continue; + } + + if (normalizedDirective !== "host") { + continue; + } + + for (const alias of rawArgs) { + if (alias.length === 0 || hasSshPattern(alias)) { + continue; + } + aliases.add(alias); + } + } + + return [...aliases].toSorted((left, right) => left.localeCompare(right)); +} + +function parseKnownHostsHostnames(raw: string): ReadonlyArray { + const hostnames = new Set(); + + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + const withoutMarker = trimmed.startsWith("@") + ? trimmed.split(/\s+/u).slice(1).join(" ") + : trimmed; + const [hostField = ""] = withoutMarker.split(/\s+/u); + if (hostField.length === 0 || hostField.startsWith("|")) { + continue; + } + + for (const rawHost of hostField.split(",")) { + const host = + /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost)?.[1] ?? rawHost.replace(/:.*$/u, "").trim(); + if (host.length === 0 || hasSshPattern(host)) { + continue; + } + hostnames.add(host); + } + } + + return [...hostnames].toSorted((left, right) => left.localeCompare(right)); +} + +function readKnownHostsHostnames(filePath: string): ReadonlyArray { + if (!FS.existsSync(filePath)) { + return NO_HOSTS; + } + + return parseKnownHostsHostnames(FS.readFileSync(filePath, "utf8")); +} + +function parseSshResolveOutput(alias: string, stdout: string): DesktopSshEnvironmentTarget { + const values = new Map(); + for (const line of stdout.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + const [key, ...rest] = trimmed.split(/\s+/u); + if (!key || rest.length === 0 || values.has(key)) { + continue; + } + values.set(key, rest.join(" ").trim()); + } + + const hostname = values.get("hostname")?.trim() || alias; + const username = values.get("user")?.trim() || null; + const rawPort = values.get("port")?.trim() ?? ""; + const parsedPort = Number.parseInt(rawPort, 10); + + return { + alias, + hostname, + username, + port: Number.isInteger(parsedPort) ? parsedPort : null, + }; +} + +async function findAvailableLocalPort(): Promise { + return await new Promise((resolve, reject) => { + const server = Net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Unable to allocate a local tunnel port."))); + return; + } + const { port } = address; + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(port); + }); + }); + }); +} + +function targetConnectionKey(target: DesktopSshEnvironmentTarget): string { + return `${target.alias}\u0000${target.hostname}\u0000${target.username ?? ""}\u0000${target.port ?? ""}`; +} + +function remoteStateKey(target: DesktopSshEnvironmentTarget): string { + return Crypto.createHash("sha256").update(targetConnectionKey(target)).digest("hex").slice(0, 16); +} + +function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { + const destination = target.alias.trim() || target.hostname.trim(); + if (destination.length === 0) { + throw new Error("SSH target is missing its alias/hostname."); + } + return target.username ? `${target.username}@${destination}` : destination; +} + +function getDefaultSshAskpassDirectory(): string { + return Path.join(OS.tmpdir(), SSH_ASKPASS_DIR_NAME); +} + +function buildPosixSshAskpassScript(): string { + return [ + "#!/bin/sh", + "set -eu", + 'PROMPT="${1:-SSH authentication}"', + 'if [ "${T3_SSH_AUTH_SECRET+x}" = "x" ]; then', + ' printf "%s\\n" "$T3_SSH_AUTH_SECRET"', + " exit 0", + "fi", + "if command -v osascript >/dev/null 2>&1; then", + " T3_SSH_ASKPASS_PROMPT=\"$PROMPT\" /usr/bin/osascript <<'APPLESCRIPT'", + 'set promptText to system attribute "T3_SSH_ASKPASS_PROMPT"', + "try", + ' set dialogResult to display dialog promptText default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel"', + " text returned of dialogResult", + "on error number -128", + " error number -128", + "end try", + "APPLESCRIPT", + " exit $?", + "fi", + "if command -v zenity >/dev/null 2>&1; then", + ' zenity --password --title="SSH authentication" --text="$PROMPT"', + " exit $?", + "fi", + "if command -v kdialog >/dev/null 2>&1; then", + ' kdialog --title "SSH authentication" --password "$PROMPT"', + " exit $?", + "fi", + "if command -v ssh-askpass >/dev/null 2>&1; then", + ' ssh-askpass "$PROMPT"', + " exit $?", + "fi", + "printf 'Unable to open an SSH password prompt on this desktop.\\n' >&2", + "exit 1", + "", + ].join("\n"); +} + +function buildWindowsSshAskpassScript(): string { + return [ + "if ($env:T3_SSH_AUTH_SECRET -ne $null) {", + " [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)", + " exit 0", + "}", + "Add-Type -AssemblyName System.Windows.Forms", + "[System.Windows.Forms.Application]::EnableVisualStyles()", + '$prompt = if ($args.Length -gt 0 -and $args[0]) { $args[0] } else { "SSH authentication" }', + "$form = New-Object System.Windows.Forms.Form", + '$form.Text = "SSH authentication"', + "$form.Width = 420", + "$form.Height = 185", + '$form.StartPosition = "CenterScreen"', + '$form.FormBorderStyle = "FixedDialog"', + "$form.MaximizeBox = $false", + "$form.MinimizeBox = $false", + "$form.TopMost = $true", + "$label = New-Object System.Windows.Forms.Label", + "$label.Left = 16", + "$label.Top = 16", + "$label.Width = 372", + "$label.Height = 34", + "$label.Text = $prompt", + "$textbox = New-Object System.Windows.Forms.TextBox", + "$textbox.Left = 16", + "$textbox.Top = 60", + "$textbox.Width = 372", + "$textbox.UseSystemPasswordChar = $true", + "$okButton = New-Object System.Windows.Forms.Button", + '$okButton.Text = "OK"', + "$okButton.Left = 232", + "$okButton.Top = 100", + "$okButton.Width = 75", + "$cancelButton = New-Object System.Windows.Forms.Button", + '$cancelButton.Text = "Cancel"', + "$cancelButton.Left = 313", + "$cancelButton.Top = 100", + "$cancelButton.Width = 75", + "$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK", + "$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel", + "$form.AcceptButton = $okButton", + "$form.CancelButton = $cancelButton", + "$form.Controls.Add($label)", + "$form.Controls.Add($textbox)", + "$form.Controls.Add($okButton)", + "$form.Controls.Add($cancelButton)", + "$result = $form.ShowDialog()", + "if ($result -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 }", + "[Console]::Out.WriteLine($textbox.Text)", + "", + ].join("\r\n"); +} + +function buildSshAskpassHelperDescriptor(input?: { + readonly directory?: string; + readonly platform?: NodeJS.Platform; +}): SshAskpassHelperDescriptor { + const platform = input?.platform ?? process.platform; + const directory = input?.directory ?? getDefaultSshAskpassDirectory(); + const pathModule = platform === "win32" ? Path.win32 : Path.posix; + + if (platform === "win32") { + const powershellPath = pathModule.join(directory, "ssh-askpass.ps1"); + return { + launcherPath: pathModule.join(directory, "ssh-askpass.cmd"), + files: [ + { + path: pathModule.join(directory, "ssh-askpass.cmd"), + contents: [ + "@echo off", + 'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*', + "", + ].join("\r\n"), + }, + { + path: powershellPath, + contents: buildWindowsSshAskpassScript(), + }, + ], + }; + } + + return { + launcherPath: pathModule.join(directory, "ssh-askpass.sh"), + files: [ + { + path: pathModule.join(directory, "ssh-askpass.sh"), + contents: buildPosixSshAskpassScript(), + mode: 0o700, + }, + ], + }; +} + +function ensureSshAskpassHelpers(input?: { + readonly directory?: string; + readonly platform?: NodeJS.Platform; +}): string { + const descriptor = buildSshAskpassHelperDescriptor(input); + const platform = input?.platform ?? process.platform; + FS.mkdirSync(Path.dirname(descriptor.launcherPath), { recursive: true }); + + for (const file of descriptor.files) { + const current = + FS.existsSync(file.path) && FS.statSync(file.path).isFile() + ? FS.readFileSync(file.path, "utf8") + : null; + if (current !== file.contents) { + FS.writeFileSync(file.path, file.contents, "utf8"); + } + if (file.mode !== undefined && platform !== "win32") { + FS.chmodSync(file.path, file.mode); + } + } + + return descriptor.launcherPath; +} + +function buildSshChildEnvironment(input?: { + readonly interactiveAuth?: boolean; + readonly baseEnv?: NodeJS.ProcessEnv; + readonly askpassDirectory?: string; + readonly authSecret?: string | null; + readonly platform?: NodeJS.Platform; +}): NodeJS.ProcessEnv { + const baseEnv = { ...(input?.baseEnv ?? process.env) }; + if (!input?.interactiveAuth) { + return baseEnv; + } + + const platform = input?.platform ?? process.platform; + const askpassInput = + input?.askpassDirectory === undefined + ? { platform } + : { + directory: input.askpassDirectory, + platform, + }; + return { + ...baseEnv, + SSH_ASKPASS: ensureSshAskpassHelpers(askpassInput), + SSH_ASKPASS_REQUIRE: "force", + ...(input?.authSecret === undefined ? {} : { T3_SSH_AUTH_SECRET: input.authSecret ?? "" }), + ...(platform === "win32" || baseEnv.DISPLAY ? {} : { DISPLAY: "t3code" }), + }; +} + +function baseSshArgs( + target: DesktopSshEnvironmentTarget, + input?: { readonly batchMode?: "yes" | "no" }, +): string[] { + return [ + "-o", + `BatchMode=${input?.batchMode ?? "no"}`, + "-o", + "ConnectTimeout=10", + ...(target.port !== null ? ["-p", String(target.port)] : []), + ]; +} + +function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { + const cleaned = stderr.trim(); + return cleaned.length > 0 ? cleaned : fallbackMessage; +} + +function isSshAuthFailure(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /permission denied|authentication failed|keyboard-interactive/u.test( + message.toLowerCase(), + ); +} + +async function runSshCommand( + target: DesktopSshEnvironmentTarget, + input?: { + readonly preHostArgs?: ReadonlyArray; + readonly remoteCommandArgs?: ReadonlyArray; + readonly stdin?: string; + readonly signal?: AbortSignal; + readonly authSecret?: string | null; + readonly batchMode?: "yes" | "no"; + readonly interactiveAuth?: boolean; + }, +): Promise { + const hostSpec = buildSshHostSpec(target); + + return await new Promise((resolve, reject) => { + const childEnvironment = + input?.interactiveAuth === undefined + ? buildSshChildEnvironment() + : buildSshChildEnvironment({ + interactiveAuth: input.interactiveAuth, + ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), + }); + const child = ChildProcess.spawn( + "ssh", + [ + ...baseSshArgs(target, { + batchMode: input?.batchMode ?? (input?.interactiveAuth ? "no" : "yes"), + }), + ...(input?.preHostArgs ?? []), + hostSpec, + ...(input?.remoteCommandArgs ?? []), + ], + { + env: childEnvironment, + stdio: "pipe", + }, + ); + + let stdout = ""; + let stderr = ""; + + const onAbort = () => { + child.kill("SIGTERM"); + reject(new Error(`SSH command aborted for ${hostSpec}.`)); + }; + + input?.signal?.addEventListener("abort", onAbort, { once: true }); + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", (error) => { + input?.signal?.removeEventListener("abort", onAbort); + reject(error); + }); + child.once("close", (code) => { + input?.signal?.removeEventListener("abort", onAbort); + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + reject( + new Error( + normalizeSshErrorMessage(stderr, `SSH command failed for ${hostSpec} (exit ${code}).`), + ), + ); + }); + + if (input?.stdin !== undefined) { + child.stdin?.end(input.stdin); + return; + } + child.stdin?.end(); + }); +} + +async function resolveDesktopSshTarget(alias: string): Promise { + const trimmedAlias = alias.trim(); + if (trimmedAlias.length === 0) { + throw new Error("SSH host alias is required."); + } + + try { + const result = await runSshCommand( + { + alias: trimmedAlias, + hostname: trimmedAlias, + username: null, + port: null, + }, + { preHostArgs: ["-G"] }, + ); + return parseSshResolveOutput(trimmedAlias, result.stdout); + } catch { + return { + alias: trimmedAlias, + hostname: trimmedAlias, + username: null, + port: null, + }; + } +} + +function buildRemoteLaunchScript(): string { + const runnerScript = buildRemoteT3RunnerScript(); + return ` +set -eu +STATE_KEY="$1" +STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" +SERVER_HOME="$STATE_DIR/server-home" +PORT_FILE="$STATE_DIR/port" +PID_FILE="$STATE_DIR/pid" +LOG_FILE="$STATE_DIR/server.log" +RUNNER_FILE="$STATE_DIR/run-t3.sh" +mkdir -p "$STATE_DIR" "$SERVER_HOME" +cat >"$RUNNER_FILE" <<'SH' +${runnerScript} +SH +chmod 700 "$RUNNER_FILE" +pick_port() { + node - "$PORT_FILE" <<'NODE' +const fs = require("node:fs"); +const net = require("node:net"); +const filePath = process.argv[2]; +const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : ""; +const preferred = Number.parseInt(raw, 10); +const start = Number.isInteger(preferred) ? preferred : ${DEFAULT_REMOTE_PORT}; +const end = start + ${REMOTE_PORT_SCAN_WINDOW}; + +function tryPort(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(error ? false : port)); + }); + }); +} + +(async () => { + for (let port = start; port < end; port += 1) { + const available = await tryPort(port); + if (available) { + process.stdout.write(String(port)); + return; + } + } + process.exit(1); +})().catch(() => process.exit(1)); +NODE +} +REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" +REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" +if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then + : +else + REMOTE_PORT="$(pick_port)" + nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & + REMOTE_PID="$!" + printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" +fi +printf '{"remotePort":%s}\\n' "$REMOTE_PORT" +`.trimStart(); +} + +function buildRemoteT3RunnerScript(): string { + return [ + "#!/bin/sh", + "set -eu", + "if command -v t3 >/dev/null 2>&1; then", + ' exec t3 "$@"', + "fi", + "if command -v npx >/dev/null 2>&1; then", + ' exec npx --yes t3 "$@"', + "fi", + "if command -v npm >/dev/null 2>&1; then", + ' exec npm exec --yes t3 -- "$@"', + "fi", + "printf 'Remote host is missing the t3 CLI and could not find npx or npm on PATH.\\n' >&2", + "exit 1", + ].join("\n"); +} + +function buildRemotePairingScript(target: DesktopSshEnvironmentTarget): string { + const runnerScript = buildRemoteT3RunnerScript(); + return ` +set -eu +STATE_DIR="$HOME/.t3/ssh-launch/${remoteStateKey(target)}" +SERVER_HOME="$STATE_DIR/server-home" +RUNNER_FILE="$STATE_DIR/run-t3.sh" +mkdir -p "$STATE_DIR" "$SERVER_HOME" +cat >"$RUNNER_FILE" <<'SH' +${runnerScript} +SH +chmod 700 "$RUNNER_FILE" +"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json +`.trimStart(); +} + +async function launchOrReuseRemoteServer( + target: DesktopSshEnvironmentTarget, + input?: SshAuthOptions, +): Promise { + const result = await runSshCommand(target, { + remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], + stdin: buildRemoteLaunchScript(), + ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), + ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), + ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), + }); + const line = result.stdout + .trim() + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .findLast((entry) => entry.length > 0); + if (!line) { + throw new Error("SSH launch did not return a remote port."); + } + + const parsed = JSON.parse(line) as { remotePort?: unknown }; + if (typeof parsed.remotePort !== "number" || !Number.isInteger(parsed.remotePort)) { + throw new Error("SSH launch returned an invalid remote port."); + } + return parsed.remotePort; +} + +async function issueRemotePairingToken( + target: DesktopSshEnvironmentTarget, + input?: SshAuthOptions, +): Promise { + const result = await runSshCommand(target, { + remoteCommandArgs: ["sh", "-s"], + stdin: buildRemotePairingScript(target), + ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), + ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), + ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), + }); + const parsed = JSON.parse(result.stdout) as { credential?: unknown }; + if (typeof parsed.credential !== "string" || parsed.credential.trim().length === 0) { + throw new Error("SSH pairing command returned an invalid credential."); + } + return parsed.credential; +} + +async function stopTunnel(entry: SshTunnelEntry): Promise { + const child = entry.process; + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + let settled = false; + let forceKillTimer: ReturnType | null = null; + + const settle = () => { + if (settled) { + return; + } + settled = true; + child.off("exit", onExit); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + resolve(); + }; + + const onExit = () => { + settle(); + }; + + child.once("exit", onExit); + child.kill("SIGTERM"); + forceKillTimer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, TUNNEL_SHUTDOWN_TIMEOUT_MS); + forceKillTimer.unref(); + }); +} + +export async function discoverDesktopSshHosts(input?: { + readonly homeDir?: string; +}): Promise { + const sshDirectory = Path.join(input?.homeDir ?? OS.homedir(), ".ssh"); + const configAliases = collectSshConfigAliasesFromFile(Path.join(sshDirectory, "config")); + const knownHosts = readKnownHostsHostnames(Path.join(sshDirectory, "known_hosts")); + const discovered = new Map(); + + for (const alias of configAliases) { + discovered.set(alias, { + alias, + hostname: alias, + username: null, + port: null, + source: "ssh-config", + }); + } + + for (const hostname of knownHosts) { + if (discovered.has(hostname)) { + continue; + } + discovered.set(hostname, { + alias: hostname, + hostname, + username: null, + port: null, + source: "known-hosts", + }); + } + + return [...discovered.values()].toSorted((left, right) => left.alias.localeCompare(right.alias)); +} + +export class DesktopSshEnvironmentManager { + private readonly tunnels = new Map(); + private readonly authSecrets = new Map(); + + constructor(private readonly options: DesktopSshEnvironmentManagerOptions = {}) {} + + private async promptForPassword( + target: DesktopSshEnvironmentTarget, + attempt: number, + ): Promise { + const passwordProvider = this.options.passwordProvider; + if (!passwordProvider) { + throw new Error(`SSH authentication failed for ${buildSshHostSpec(target)}.`); + } + + const password = await passwordProvider({ + attempt, + destination: target.alias.trim() || target.hostname.trim(), + username: target.username, + prompt: `Enter the SSH password for ${buildSshHostSpec(target)}.`, + }); + if (password === null) { + throw new Error(`SSH authentication cancelled for ${buildSshHostSpec(target)}.`); + } + return password; + } + + private async runWithSshAuth( + key: string, + target: DesktopSshEnvironmentTarget, + operation: (authOptions: SshAuthOptions) => Promise, + ): Promise { + let authSecret = this.authSecrets.get(key) ?? null; + let promptCount = 0; + + while (true) { + try { + return await operation( + authSecret === null + ? { + batchMode: this.options.passwordProvider ? "yes" : "no", + interactiveAuth: !this.options.passwordProvider, + } + : { + authSecret, + batchMode: "no", + interactiveAuth: true, + }, + ); + } catch (error) { + if (!isSshAuthFailure(error)) { + throw error; + } + + if (!this.options.passwordProvider) { + throw error; + } + + if (authSecret !== null) { + this.authSecrets.delete(key); + } + if (promptCount >= 2) { + throw error; + } + + promptCount += 1; + authSecret = await this.promptForPassword(target, promptCount); + this.authSecrets.set(key, authSecret); + } + } + } + + async discoverHosts(): Promise { + return await discoverDesktopSshHosts(); + } + + async ensureEnvironment( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ): Promise { + const resolvedTarget = await resolveDesktopSshTarget(target.alias || target.hostname); + const key = targetConnectionKey(resolvedTarget); + let entry = this.tunnels.get(key) ?? null; + + if (entry !== null) { + try { + await waitForHttpReady(entry.httpBaseUrl, { timeoutMs: 2_000 }); + } catch { + await stopTunnel(entry).catch(() => undefined); + this.tunnels.delete(key); + entry = null; + } + } + + if (entry === null) { + const remotePort = await this.runWithSshAuth(key, resolvedTarget, (authOptions) => + launchOrReuseRemoteServer(resolvedTarget, authOptions), + ); + const localPort = await findAvailableLocalPort(); + const httpBaseUrl = `http://127.0.0.1:${localPort}/`; + const wsBaseUrl = `ws://127.0.0.1:${localPort}/`; + entry = await this.runWithSshAuth(key, resolvedTarget, async (authOptions) => { + const process = ChildProcess.spawn( + "ssh", + [ + ...baseSshArgs(resolvedTarget, { batchMode: authOptions.batchMode ?? "no" }), + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + "-N", + "-L", + `${localPort}:127.0.0.1:${remotePort}`, + buildSshHostSpec(resolvedTarget), + ], + { + env: buildSshChildEnvironment({ + ...(authOptions.authSecret === undefined + ? {} + : { authSecret: authOptions.authSecret }), + ...(authOptions.interactiveAuth === undefined + ? {} + : { interactiveAuth: authOptions.interactiveAuth }), + }), + stdio: "pipe", + }, + ); + const nextEntry: SshTunnelEntry = { + key, + target: resolvedTarget, + remotePort, + localPort, + httpBaseUrl, + wsBaseUrl, + process, + }; + const tunnelReady = new Promise((resolve, reject) => { + let stderr = ""; + process.stderr?.setEncoding("utf8"); + process.stderr?.on("data", (chunk: string) => { + stderr += chunk; + }); + process.once("error", (error) => { + this.tunnels.delete(key); + reject(error); + }); + process.once("exit", (code) => { + this.tunnels.delete(key); + reject( + new Error( + normalizeSshErrorMessage( + stderr, + `SSH tunnel exited unexpectedly for ${resolvedTarget.alias} (exit ${code ?? "unknown"}).`, + ), + ), + ); + }); + waitForHttpReady(httpBaseUrl, { timeoutMs: SSH_READY_TIMEOUT_MS }) + .then(() => resolve()) + .catch((error) => reject(error)); + }); + this.tunnels.set(key, nextEntry); + try { + await tunnelReady; + return nextEntry; + } catch (error) { + await stopTunnel(nextEntry).catch(() => undefined); + this.tunnels.delete(key); + throw error; + } + }); + } + + const pairingToken = options?.issuePairingToken + ? await this.runWithSshAuth(key, entry.target, (authOptions) => + issueRemotePairingToken(entry.target, authOptions), + ) + : null; + + return { + target: entry.target, + httpBaseUrl: entry.httpBaseUrl, + wsBaseUrl: entry.wsBaseUrl, + pairingToken, + }; + } + + async dispose(): Promise { + const entries = [...this.tunnels.values()]; + this.tunnels.clear(); + await Promise.all(entries.map((entry) => stopTunnel(entry).catch(() => undefined))); + } +} + +export const __test = { + baseSshArgs, + buildRemoteLaunchScript, + buildRemotePairingScript, + buildRemoteT3RunnerScript, + buildSshAskpassHelperDescriptor, + buildSshChildEnvironment, + isSshAuthFailure, + collectSshConfigAliasesFromFile, + parseKnownHostsHostnames, + parseSshResolveOutput, +}; diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index a74ce18ac30..e02168e09b3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -10,6 +10,12 @@ const savedRegistryRecord: PersistedSavedEnvironmentRecord = { wsBaseUrl: "wss://remote.example.com/", createdAt: "2026-04-09T00:00:00.000Z", lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, }; function createLocalStorageStub(): Storage { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 70f51d5c30a..30c949b37ac 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -19,6 +19,14 @@ const BrowserSavedEnvironmentRecordSchema = Schema.Struct({ wsBaseUrl: Schema.String, createdAt: Schema.String, lastConnectedAt: Schema.NullOr(Schema.String), + desktopSsh: Schema.optionalKey( + Schema.Struct({ + alias: Schema.String, + hostname: Schema.String, + username: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + }), + ), bearerToken: Schema.optionalKey(Schema.String), }); type BrowserSavedEnvironmentRecord = typeof BrowserSavedEnvironmentRecordSchema.Type; @@ -37,7 +45,7 @@ function hasWindow(): boolean { function toPersistedSavedEnvironmentRecord( record: PersistedSavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -45,6 +53,7 @@ function toPersistedSavedEnvironmentRecord( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; } export function readBrowserClientSettings(): ClientSettings | null { @@ -135,6 +144,7 @@ export function writeBrowserSavedEnvironmentRegistry( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), bearerToken, } : toPersistedSavedEnvironmentRecord(record); @@ -166,7 +176,7 @@ export function writeBrowserSavedEnvironmentSecret( return record; } found = true; - return { + const nextRecord = { environmentId: record.environmentId, label: record.label, httpBaseUrl: record.httpBaseUrl, @@ -174,7 +184,8 @@ export function writeBrowserSavedEnvironmentSecret( createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, bearerToken: secret, - } satisfies BrowserSavedEnvironmentRecord; + }; + return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; }), }); return found; diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx new file mode 100644 index 00000000000..4066544c633 --- /dev/null +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -0,0 +1,114 @@ +import type { DesktopSshPasswordPromptRequest } from "@t3tools/contracts"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; + +function describeSshTarget(request: DesktopSshPasswordPromptRequest): string { + return request.username ? `${request.username}@${request.destination}` : request.destination; +} + +export function SshPasswordPromptDialog() { + const [queue, setQueue] = useState([]); + const [password, setPassword] = useState(""); + const currentRequest = queue[0] ?? null; + const inputRef = useRef(null); + + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge?.onSshPasswordPrompt) { + return; + } + + return bridge.onSshPasswordPrompt((request) => { + setQueue((currentQueue) => [...currentQueue, request]); + }); + }, []); + + useEffect(() => { + setPassword(""); + if (!currentRequest) { + return; + } + + const frame = window.requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => { + window.cancelAnimationFrame(frame); + }; + }, [currentRequest]); + + const respond = async (nextPassword: string | null) => { + if (!currentRequest) { + return; + } + + const requestId = currentRequest.requestId; + setQueue((currentQueue) => currentQueue.slice(1)); + setPassword(""); + try { + await window.desktopBridge?.resolveSshPasswordPrompt(requestId, nextPassword); + } catch (error) { + console.error("Failed to resolve SSH password prompt.", error); + } + }; + + const target = currentRequest ? describeSshTarget(currentRequest) : null; + + return ( + { + if (!open) { + void respond(null); + } + }} + > + + + SSH Password Required + + T3 needs your SSH password to connect to{" "} + {target ? {target} : "the remote host"}. The password is passed to the + local SSH process for this connection attempt and is not saved by T3 Code. + + + +
+

{currentRequest?.prompt}

+ setPassword(event.target.value)} + /> +
+

+ Use SSH keys to avoid repeated password prompts on new SSH sessions. +

+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 31253781930..061feb4add5 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,9 +1,11 @@ -import { ChevronDownIcon, PlusIcon, QrCodeIcon } from "lucide-react"; +import { ChevronDownIcon, PlusIcon, QrCodeIcon, RefreshCwIcon } from "lucide-react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, type AdvertisedEndpoint, + type DesktopDiscoveredSshHost, + type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; @@ -30,6 +32,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; import { AlertDialog, AlertDialogClose, @@ -67,6 +70,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + connectDesktopSshEnvironment, getPrimaryEnvironmentConnection, reconnectSavedEnvironment, removeSavedEnvironment, @@ -165,6 +169,69 @@ function getSavedBackendStatusTooltip( : "Not connected yet."; } +function formatDesktopSshTarget(target: NonNullable): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +function parseManualDesktopSshTarget(input: { + readonly host: string; + readonly username: string; + readonly port: string; +}): DesktopSshEnvironmentTarget { + const rawHost = input.host.trim(); + if (rawHost.length === 0) { + throw new Error("SSH host or alias is required."); + } + + let hostname = rawHost; + let username = input.username.trim() || null; + let port: number | null = null; + + const atIndex = hostname.lastIndexOf("@"); + if (atIndex > 0) { + const inlineUsername = hostname.slice(0, atIndex).trim(); + hostname = hostname.slice(atIndex + 1).trim(); + if (!username && inlineUsername.length > 0) { + username = inlineUsername; + } + } + + const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); + if (bracketedHostMatch) { + hostname = bracketedHostMatch[1]!.trim(); + if (bracketedHostMatch[2]) { + port = Number.parseInt(bracketedHostMatch[2], 10); + } + } else { + const colonSegments = hostname.split(":"); + if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { + hostname = colonSegments[0]!.trim(); + port = Number.parseInt(colonSegments[1]!, 10); + } + } + + const rawPort = input.port.trim(); + if (rawPort.length > 0) { + port = Number.parseInt(rawPort, 10); + } + + if (hostname.length === 0) { + throw new Error("SSH host or alias is required."); + } + + if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { + throw new Error("SSH port must be between 1 and 65535."); + } + + return { + alias: hostname, + hostname, + username, + port, + }; +} + /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; @@ -933,6 +1000,7 @@ function SavedBackendListRow({ const descriptorLabel = runtime?.descriptor?.label ?? null; const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); const metadataBits = [ + record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, roleLabel, record.lastConnectedAt ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` @@ -983,6 +1051,59 @@ function SavedBackendListRow({ ); } +interface DesktopSshHostRowProps { + target: DesktopDiscoveredSshHost; + savedRecord: SavedEnvironmentRecord | null; + connectingHostAlias: string | null; + onConnect: (target: DesktopDiscoveredSshHost) => void; +} + +const DesktopSshHostRow = memo(function DesktopSshHostRow({ + target, + savedRecord, + connectingHostAlias, + onConnect, +}: DesktopSshHostRowProps) { + const address = formatDesktopSshTarget(target); + const secondaryBits = [target.source === "ssh-config" ? "SSH config" : "Known hosts", address]; + const buttonLabel = + connectingHostAlias === target.alias ? "Connecting…" : savedRecord ? "Reconnect" : "Connect"; + + return ( +
+
+
+
+

{target.alias}

+
+

{secondaryBits.join(" · ")}

+ {savedRecord ? ( +

+ Saved as {savedRecord.label} + {savedRecord.lastConnectedAt + ? ` · Last connected ${formatAccessTimestamp(savedRecord.lastConnectedAt)}` + : ""} +

+ ) : null} +
+
+ +
+
+
+ ); +}); + export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const [currentSessionRole, setCurrentSessionRole] = useState<"owner" | "client" | null>( @@ -999,6 +1120,26 @@ export function ConnectionsSettings() { .map((record) => record.environmentId), [savedEnvironmentsById], ); + const savedDesktopSshEnvironmentsByAlias = useMemo( + () => + Object.values(savedEnvironmentsById).reduce>( + (accumulator, record) => { + if (record.desktopSsh?.alias) { + accumulator[record.desktopSsh.alias] = record; + } + return accumulator; + }, + {}, + ), + [savedEnvironmentsById], + ); + const [discoveredSshHosts, setDiscoveredSshHosts] = useState< + ReadonlyArray + >([]); + const [hasLoadedDiscoveredSshHosts, setHasLoadedDiscoveredSshHosts] = useState(false); + const [isLoadingDiscoveredSshHosts, setIsLoadingDiscoveredSshHosts] = useState(false); + const [discoveredSshHostsError, setDiscoveredSshHostsError] = useState(null); + const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); const [desktopServerExposureState, setDesktopServerExposureState] = useState(null); @@ -1024,13 +1165,17 @@ export function ConnectionsSettings() { >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); const [addBackendDialogOpen, setAddBackendDialogOpen] = useState(false); - const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code">( + const [savedBackendMode, setSavedBackendMode] = useState<"pairing-url" | "host-code" | "ssh">( "pairing-url", ); const [savedBackendLabel, setSavedBackendLabel] = useState(""); const [savedBackendPairingUrl, setSavedBackendPairingUrl] = useState(""); const [savedBackendHost, setSavedBackendHost] = useState(""); const [savedBackendPairingCode, setSavedBackendPairingCode] = useState(""); + const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); + const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); + const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); + const [isManualSshFormOpen, setIsManualSshFormOpen] = useState(false); const [savedBackendError, setSavedBackendError] = useState(null); const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = @@ -1158,6 +1303,47 @@ export function ConnectionsSettings() { }, []); const handleAddSavedBackend = useCallback(async () => { + if (savedBackendMode === "ssh") { + setIsAddingSavedBackend(true); + setSavedBackendError(null); + try { + const target = parseManualDesktopSshTarget({ + host: savedBackendSshHost, + username: savedBackendSshUsername, + port: savedBackendSshPort, + }); + const record = await connectDesktopSshEnvironment(target, { + label: savedBackendLabel, + }); + setSavedBackendLabel(""); + setSavedBackendPairingUrl(""); + setSavedBackendHost(""); + setSavedBackendPairingCode(""); + setSavedBackendSshHost(""); + setSavedBackendSshUsername(""); + setSavedBackendSshPort(""); + setIsManualSshFormOpen(false); + setAddBackendDialogOpen(false); + toastManager.add({ + type: "success", + title: "Environment connected", + description: `${record.label} is ready over an SSH-managed tunnel.`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to connect SSH host."; + setIsManualSshFormOpen(true); + setSavedBackendError(message); + toastManager.add({ + type: "error", + title: "Could not connect SSH host", + description: message, + }); + } finally { + setIsAddingSavedBackend(false); + } + return; + } + setIsAddingSavedBackend(true); setSavedBackendError(null); try { @@ -1174,6 +1360,10 @@ export function ConnectionsSettings() { setSavedBackendPairingUrl(""); setSavedBackendHost(""); setSavedBackendPairingCode(""); + setSavedBackendSshHost(""); + setSavedBackendSshUsername(""); + setSavedBackendSshPort(""); + setIsManualSshFormOpen(false); setAddBackendDialogOpen(false); toastManager.add({ type: "success", @@ -1199,6 +1389,9 @@ export function ConnectionsSettings() { savedBackendMode, savedBackendPairingCode, savedBackendPairingUrl, + savedBackendSshHost, + savedBackendSshPort, + savedBackendSshUsername, ]); const handleReconnectSavedBackend = useCallback(async (environmentId: EnvironmentId) => { @@ -1241,6 +1434,90 @@ export function ConnectionsSettings() { } }, []); + const loadDiscoveredSshHosts = useCallback(async () => { + if (!desktopBridge) { + setDiscoveredSshHosts([]); + setHasLoadedDiscoveredSshHosts(false); + setDiscoveredSshHostsError(null); + return; + } + + setIsLoadingDiscoveredSshHosts(true); + setDiscoveredSshHostsError(null); + try { + const hosts = await desktopBridge.discoverSshHosts(); + setDiscoveredSshHosts(hosts); + setHasLoadedDiscoveredSshHosts(true); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to discover SSH hosts."; + setDiscoveredSshHostsError(message); + setHasLoadedDiscoveredSshHosts(true); + } finally { + setIsLoadingDiscoveredSshHosts(false); + } + }, [desktopBridge]); + + const handleConnectSshHost = useCallback( + async (target: DesktopSshEnvironmentTarget, label?: string) => { + setConnectingSshHostAlias(target.alias); + if (savedBackendMode === "ssh") { + setSavedBackendError(null); + } else { + setDiscoveredSshHostsError(null); + } + try { + const record = await connectDesktopSshEnvironment( + target, + label === undefined ? undefined : { label }, + ); + setSavedBackendLabel(""); + setSavedBackendSshHost(""); + setSavedBackendSshUsername(""); + setSavedBackendSshPort(""); + setAddBackendDialogOpen(false); + toastManager.add({ + type: "success", + title: savedDesktopSshEnvironmentsByAlias[target.alias] + ? "Environment reconnected" + : "Environment connected", + description: `${record.label} is ready over an SSH-managed tunnel.`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to connect SSH host."; + if (savedBackendMode === "ssh") { + setSavedBackendError(message); + } else { + setDiscoveredSshHostsError(message); + } + toastManager.add({ + type: "error", + title: "Could not connect SSH host", + description: message, + }); + } finally { + setConnectingSshHostAlias(null); + } + }, + [savedBackendMode, savedDesktopSshEnvironmentsByAlias], + ); + + useEffect(() => { + if (!desktopBridge || !addBackendDialogOpen || savedBackendMode !== "ssh") { + return; + } + if (hasLoadedDiscoveredSshHosts || isLoadingDiscoveredSshHosts) { + return; + } + void loadDiscoveredSshHosts(); + }, [ + addBackendDialogOpen, + desktopBridge, + hasLoadedDiscoveredSshHosts, + isLoadingDiscoveredSshHosts, + loadDiscoveredSshHosts, + savedBackendMode, + ]); + useEffect(() => { if (desktopBridge) { setCurrentSessionRole("owner"); @@ -1598,6 +1875,7 @@ export function ConnectionsSettings() { setAddBackendDialogOpen(open); if (!open) { setSavedBackendError(null); + setIsManualSshFormOpen(false); } }} > @@ -1609,7 +1887,7 @@ export function ConnectionsSettings() { } /> - + Add Environment Pair another environment to this client. @@ -1640,6 +1918,21 @@ export function ConnectionsSettings() { > Host + code + {desktopBridge ? ( + + ) : null} @@ -1648,25 +1941,30 @@ export function ConnectionsSettings() {

Enter the full pairing URL from the environment you want to connect to.

- ) : ( + ) : savedBackendMode === "host-code" ? (

Enter the backend host and pairing code separately.

+ ) : ( +

+ Connect over SSH using an existing alias from ~/.ssh/config or + enter a host directly. T3 uses your current SSH agent and config. +

)} -
- - {savedBackendMode === "pairing-url" ? ( + {savedBackendMode === "pairing-url" ? ( +
+ - ) : ( - <> -
+ ) : savedBackendMode === "host-code" ? ( +
+ + + +
+ ) : ( +
+
+
+

Discovered hosts

+

+ From ~/.ssh/config and known_hosts. +

+
+ +
+ {discoveredSshHostsError ? ( +

{discoveredSshHostsError}

+ ) : null} +
+ {discoveredSshHosts.map((target) => ( + void handleConnectSshHost(nextTarget)} /> - -
+ {savedBackendError ? ( +

{savedBackendError}

+ ) : null} + + +
+

+ Enter a host manually +

+

+ Use an alias or enter host, username, and port directly. +

+
+ - - - )} -
- {savedBackendError ? ( + + +
+ + +
+ + +
+ + Uses your existing SSH keys, agent, and config. Password and + keyboard-interactive prompts open through your system SSH dialog when + needed. + + +
+
+ +
+ )} + {savedBackendMode !== "ssh" && savedBackendError ? (

{savedBackendError}

) : null} - + {savedBackendMode !== "ssh" ? ( + + ) : null}
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 92c59178894..6a700034054 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -114,6 +114,8 @@ const authAccessHarness = vi.hoisted(() => { }; }); +const mockConnectDesktopSshEnvironment = vi.fn(); + vi.mock("../../environments/runtime", () => { const primaryConnection = { kind: "primary" as const, @@ -151,6 +153,7 @@ vi.mock("../../environments/runtime", () => { new URL(path, "http://localhost:3000").toString(), waitForSavedEnvironmentRegistryHydration: async () => undefined, addSavedEnvironment: vi.fn(), + connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, disconnectSavedEnvironment: vi.fn(), ensureEnvironmentConnectionBootstrapped: async () => undefined, getPrimaryEnvironmentConnection: () => primaryConnection, @@ -258,6 +261,7 @@ function makeClientSession(input: { } const createDesktopBridgeStub = (overrides?: { + readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; readonly serverExposureState?: Awaited>; readonly advertisedEndpoints?: Awaited>; readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; @@ -295,6 +299,50 @@ const createDesktopBridgeStub = (overrides?: { getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), + discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), + ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ + target, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + pairingToken: "ssh-pairing-token", + })), + fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ + environmentId: "environment-ssh", + label: "SSH environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + bootstrapSshBearerSession: vi.fn().mockResolvedValue({ + authenticated: true, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-05-01T12:00:00.000Z", + sessionToken: "ssh-bearer-token", + }), + fetchSshSessionState: vi.fn().mockResolvedValue({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-05-01T12:00:00.000Z", + }), + issueSshWebSocketToken: vi.fn().mockResolvedValue({ + token: "ssh-ws-token", + expiresAt: "2026-05-01T12:05:00.000Z", + }), + onSshPasswordPrompt: vi.fn(() => () => {}), + resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), getServerExposureState: vi.fn().mockResolvedValue( overrides?.serverExposureState ?? { mode: "local-only", @@ -348,6 +396,7 @@ describe("GeneralSettingsPanel observability", () => { localStorage.clear(); useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); authAccessHarness.reset(); + mockConnectDesktopSshEnvironment.mockReset(); }); afterEach(async () => { @@ -852,6 +901,70 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("adds desktop ssh environments from the add-environment dialog", async () => { + const discoverSshHosts = vi.fn().mockResolvedValue([ + { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + source: "ssh-config" as const, + }, + ]); + window.desktopBridge = createDesktopBridgeStub({ + discoverSshHosts, + }); + mockConnectDesktopSshEnvironment.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-devbox"), + label: "Build box", + wsBaseUrl: "ws://127.0.0.1:3774/", + httpBaseUrl: "http://127.0.0.1:3774/", + createdAt: "2036-04-07T00:00:00.000Z", + lastConnectedAt: "2036-04-07T00:00:00.000Z", + desktopSsh: { + alias: "devbox.example.com", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + }); + + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + await page.getByRole("button", { name: "Add environment", exact: true }).click(); + await expect.element(page.getByText("Add Environment")).toBeInTheDocument(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + await vi.waitFor(() => { + expect(discoverSshHosts).toHaveBeenCalledTimes(1); + }); + await expect.element(page.getByText("devbox")).toBeInTheDocument(); + + await page.getByText("Enter a host manually").click(); + await page.getByLabelText("Label").fill("Build box"); + await page.getByLabelText("SSH host or alias").fill("devbox.example.com"); + await page.getByLabelText("Username").fill("julius"); + await page.getByLabelText("Port").fill("2222"); + await page.getByRole("button", { name: "Connect SSH host", exact: true }).click(); + + await vi.waitFor(() => { + expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( + { + alias: "devbox.example.com", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + { label: "Build box" }, + ); + }); + }); + it("opens the logs folder in the preferred editor", async () => { const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 080ac809975..bebe1b0ee03 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props) return ( ({ resolveRemotePairingTarget: mockResolveRemotePairingTarget, @@ -18,6 +22,7 @@ vi.mock("../remote/api", () => ({ bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, fetchRemoteSessionState: vi.fn(), + isRemoteEnvironmentAuthHttpError: vi.fn(() => false), resolveRemoteWebSocketConnectionUrl: vi.fn(), })); @@ -55,18 +60,37 @@ vi.mock("./catalog", () => ({ })); vi.mock("./connection", () => ({ - createEnvironmentConnection: vi.fn(), + createEnvironmentConnection: mockCreateEnvironmentConnection, })); describe("addSavedEnvironment", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - mockResolveRemotePairingTarget.mockReturnValue({ - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - credential: "pairing-code", + vi.stubGlobal("window", { + desktopBridge: { + ensureSshEnvironment: mockEnsureSshEnvironment, + fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, + bootstrapSshBearerSession: mockBootstrapSshBearerSession, + fetchSshSessionState: vi.fn(), + issueSshWebSocketToken: vi.fn(), + }, }); + mockResolveRemotePairingTarget.mockImplementation( + (input: { host?: string; pairingCode?: string }) => ({ + httpBaseUrl: input.host + ? input.host.endsWith("/") + ? input.host + : `${input.host}/` + : "https://remote.example.com/", + wsBaseUrl: input.host + ? input.host.replace(/^http/u, "ws").endsWith("/") + ? input.host.replace(/^http/u, "ws") + : `${input.host.replace(/^http/u, "ws")}/` + : "wss://remote.example.com/", + credential: input.pairingCode ?? "pairing-code", + }), + ); mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ environmentId: EnvironmentId.make("environment-1"), label: "Remote environment", @@ -75,10 +99,40 @@ describe("addSavedEnvironment", () => { sessionToken: "bearer-token", role: "owner", }); + mockFetchSshEnvironmentDescriptor.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }); + mockBootstrapSshBearerSession.mockResolvedValue({ + sessionToken: "ssh-bearer-token", + role: "owner", + }); mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); mockListSavedEnvironmentRecords.mockReturnValue([]); + mockCreateEnvironmentConnection.mockImplementation( + (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ + kind: "saved", + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: async () => undefined, + }), + ); + mockEnsureSshEnvironment.mockResolvedValue({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + pairingToken: "ssh-pairing-code", + }); }); it("rolls back persisted metadata when bearer token persistence fails", async () => { @@ -102,4 +156,46 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + + it("bootstraps a desktop ssh environment through the desktop bridge", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + + const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await expect( + connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ).rejects.toThrow(); + + expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }, + { issuePairingToken: true }, + ); + expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + }); + expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); + expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( + "http://127.0.0.1:3774/", + "ssh-pairing-code", + ); + expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); + expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); + expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + + await resetEnvironmentServiceForTests(); + }); }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 6c58f9e8a53..be20cbd1fd5 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -1,5 +1,7 @@ import { type AuthSessionRole, + type DesktopSshEnvironmentBootstrap, + type DesktopSshEnvironmentTarget, type EnvironmentId, type OrchestrationEvent, type OrchestrationShellSnapshot, @@ -33,6 +35,7 @@ import { bootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, + isRemoteEnvironmentAuthHttpError, resolveRemoteWebSocketConnectionUrl, } from "../remote/api"; import { resolveRemotePairingTarget } from "../remote/target"; @@ -85,6 +88,7 @@ type ThreadDetailSubscriptionEntry = { }; const environmentConnections = new Map(); +const pendingSavedEnvironmentConnections = new Map>(); const environmentConnectionListeners = new Set<() => void>(); const threadDetailSubscriptions = new Map(); const lastAppliedProjectionVersionByEnvironment = new Map< @@ -491,6 +495,163 @@ function isoNow(): string { return new Date().toISOString(); } +function serializeSavedEnvironmentRecord(record: SavedEnvironmentRecord) { + return { + environmentId: record.environmentId, + label: record.label, + httpBaseUrl: record.httpBaseUrl, + wsBaseUrl: record.wsBaseUrl, + createdAt: record.createdAt, + lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), + } as const; +} + +function isDesktopSshTargetEqual( + left: DesktopSshEnvironmentTarget | undefined, + right: DesktopSshEnvironmentTarget | undefined, +): boolean { + if (!left || !right) { + return false; + } + + return ( + left.alias === right.alias && + left.hostname === right.hostname && + left.username === right.username && + left.port === right.port + ); +} + +function findSavedEnvironmentRecordByDesktopSshTarget( + target: DesktopSshEnvironmentTarget | undefined, +): SavedEnvironmentRecord | null { + if (!target) { + return null; + } + + return ( + listSavedEnvironmentRecords().find((record) => + isDesktopSshTargetEqual(record.desktopSsh, target), + ) ?? null + ); +} + +async function persistSavedEnvironmentRegistryRollback(): Promise { + await ensureLocalApi().persistence.setSavedEnvironmentRegistry( + listSavedEnvironmentRecords().map((entry) => serializeSavedEnvironmentRecord(entry)), + ); +} + +async function resolveDesktopSshEnvironmentBootstrap( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, +): Promise { + const desktopBridge = window.desktopBridge; + if (!desktopBridge) { + throw new Error("SSH launch is only available in the desktop app."); + } + + return await desktopBridge.ensureSshEnvironment(target, options); +} + +function getDesktopSshBridge() { + const desktopBridge = window.desktopBridge; + if (!desktopBridge) { + throw new Error("SSH launch is only available in the desktop app."); + } + return desktopBridge; +} + +async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { + return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); +} + +async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { + return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); +} + +async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { + return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); +} + +async function resolveDesktopSshWebSocketConnectionUrl( + wsBaseUrl: string, + httpBaseUrl: string, + bearerToken: string, +) { + const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken); + const url = new URL(wsBaseUrl, window.location.origin); + url.searchParams.set("wsToken", issued.token); + return url.toString(); +} + +async function prepareSavedEnvironmentRecordForConnection( + record: SavedEnvironmentRecord, + options?: { readonly issuePairingToken?: boolean }, +): Promise<{ + readonly record: SavedEnvironmentRecord; + readonly pairingToken: string | null; +}> { + if (!record.desktopSsh) { + return { record, pairingToken: null }; + } + + const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); + const nextRecord: SavedEnvironmentRecord = { + ...record, + httpBaseUrl: bootstrap.httpBaseUrl, + wsBaseUrl: bootstrap.wsBaseUrl, + desktopSsh: bootstrap.target, + }; + + if ( + nextRecord.httpBaseUrl !== record.httpBaseUrl || + nextRecord.wsBaseUrl !== record.wsBaseUrl || + !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) + ) { + await persistSavedEnvironmentRecord(nextRecord); + useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); + } + + return { + record: nextRecord, + pairingToken: bootstrap.pairingToken, + }; +} + +async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ + readonly record: SavedEnvironmentRecord; + readonly bearerToken: string; + readonly role: AuthSessionRole | null; +}> { + const prepared = await prepareSavedEnvironmentRecordForConnection(record, { + issuePairingToken: true, + }); + if (!prepared.pairingToken) { + throw new Error("Desktop SSH launch did not return a pairing token."); + } + + const bearerSession = await bootstrapDesktopSshBearerSession( + prepared.record.httpBaseUrl, + prepared.pairingToken, + ); + const didPersistBearerToken = await writeSavedEnvironmentBearerToken( + prepared.record.environmentId, + bearerSession.sessionToken, + ); + if (!didPersistBearerToken) { + await persistSavedEnvironmentRegistryRollback(); + throw new Error("Unable to persist saved environment credentials."); + } + + return { + record: prepared.record, + bearerToken: bearerSession.sessionToken, + role: bearerSession.role ?? null, + }; +} + function setRuntimeConnecting(environmentId: EnvironmentId) { useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { connectionState: "connecting", @@ -802,35 +963,46 @@ function createPrimaryEnvironmentClient( } function createSavedEnvironmentClient( - record: SavedEnvironmentRecord, + environmentId: EnvironmentId, bearerToken: string, ): WsRpcClient { - useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); + useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); return createWsRpcClient( new WsTransport( - () => - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), + async () => { + const record = getSavedEnvironmentRecord(environmentId); + if (!record) { + throw new Error(`Saved environment ${environmentId} not found.`); + } + return record.desktopSsh + ? await resolveDesktopSshWebSocketConnectionUrl( + record.wsBaseUrl, + record.httpBaseUrl, + bearerToken, + ) + : await resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: record.wsBaseUrl, + httpBaseUrl: record.httpBaseUrl, + bearerToken, + }); + }, { onAttempt: () => { - setRuntimeConnecting(record.environmentId); + setRuntimeConnecting(environmentId); }, onOpen: () => { - setRuntimeConnected(record.environmentId); + setRuntimeConnected(environmentId); }, onError: (message: string) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { + useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { connectionState: "error", lastError: message, lastErrorAt: isoNow(), }); }, onClose: (details: { readonly code: number; readonly reason: string }) => { - setRuntimeDisconnected(record.environmentId, details.reason); + setRuntimeDisconnected(environmentId, details.reason); }, }, ), @@ -838,18 +1010,25 @@ function createSavedEnvironmentClient( } async function refreshSavedEnvironmentMetadata( - record: SavedEnvironmentRecord, + environmentId: EnvironmentId, bearerToken: string, client: WsRpcClient, roleHint?: AuthSessionRole | null, configHint?: ServerConfig | null, ): Promise { + const record = getSavedEnvironmentRecord(environmentId); + if (!record) { + throw new Error(`Saved environment ${environmentId} not found.`); + } + const [serverConfig, sessionState] = await Promise.all([ configHint ? Promise.resolve(configHint) : client.server.getConfig(), - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), + record.desktopSsh + ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) + : fetchRemoteSessionState({ + httpBaseUrl: record.httpBaseUrl, + bearerToken, + }), ]); useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { @@ -923,69 +1102,124 @@ async function ensureSavedEnvironmentConnection( return existing; } - const bearerToken = - options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); - if (!bearerToken) { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: "requires-auth", - role: null, - connectionState: "disconnected", - lastError: "Saved environment is missing its saved credential. Pair it again.", - lastErrorAt: isoNow(), - }); - throw new Error("Saved environment is missing its saved credential."); + const pending = pendingSavedEnvironmentConnections.get(record.environmentId); + if (pending) { + return pending; } - const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken); - const knownEnvironment = createKnownEnvironment({ - id: record.environmentId, - label: record.label, - source: "manual", - target: { - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - }, - }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...knownEnvironment, - environmentId: record.environmentId, - }, - client, - refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata(record, bearerToken, client); - }, - onConfigSnapshot: (config) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - descriptor: config.environment, - serverConfig: config, - }); - }, - onWelcome: (payload) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - descriptor: payload.environment, - }); - }, - ...createEnvironmentConnectionHandlers(), - }); - - registerConnection(connection); + const nextConnection = (async () => { + let activeRecord = record; + let roleHint = options?.role ?? null; + let bearerToken = + options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); + if (!bearerToken) { + if (record.desktopSsh) { + const issued = await issueDesktopSshBearerSession(record); + activeRecord = issued.record; + bearerToken = issued.bearerToken; + roleHint = issued.role; + } else { + useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { + authState: "requires-auth", + role: null, + connectionState: "disconnected", + lastError: "Saved environment is missing its saved credential. Pair it again.", + lastErrorAt: isoNow(), + }); + throw new Error("Saved environment is missing its saved credential."); + } + } else { + const prepared = await prepareSavedEnvironmentRecordForConnection(record); + activeRecord = prepared.record; + } - try { - await refreshSavedEnvironmentMetadata( - record, - bearerToken, + const activeBearerToken = bearerToken; + const client = + options?.client ?? + createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); + const knownEnvironment = createKnownEnvironment({ + id: activeRecord.environmentId, + label: activeRecord.label, + source: "manual", + target: { + httpBaseUrl: activeRecord.httpBaseUrl, + wsBaseUrl: activeRecord.wsBaseUrl, + }, + }); + const connection = createEnvironmentConnection({ + kind: "saved", + knownEnvironment: { + ...knownEnvironment, + environmentId: activeRecord.environmentId, + }, client, - options?.role ?? null, - options?.serverConfig ?? null, - ); - return connection; - } catch (error) { - setRuntimeError(record.environmentId, error); - await removeConnection(record.environmentId).catch(() => false); - throw error; - } + refreshMetadata: async () => { + await refreshSavedEnvironmentMetadata( + activeRecord.environmentId, + activeBearerToken, + client, + ); + }, + onConfigSnapshot: (config) => { + useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { + descriptor: config.environment, + serverConfig: config, + }); + }, + onWelcome: (payload) => { + useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { + descriptor: payload.environment, + }); + }, + ...createEnvironmentConnectionHandlers(), + }); + + registerConnection(connection); + + try { + try { + await refreshSavedEnvironmentMetadata( + activeRecord.environmentId, + activeBearerToken, + client, + roleHint, + options?.serverConfig ?? null, + ); + } catch (error) { + if ( + !record.desktopSsh || + !isRemoteEnvironmentAuthHttpError(error) || + error.status !== 401 + ) { + throw error; + } + + const issued = await issueDesktopSshBearerSession(activeRecord); + activeRecord = issued.record; + bearerToken = issued.bearerToken; + roleHint = issued.role; + await removeConnection(activeRecord.environmentId).catch(() => false); + pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); + return await ensureSavedEnvironmentConnection(activeRecord, { + bearerToken, + role: roleHint, + serverConfig: options?.serverConfig ?? null, + }); + } + return connection; + } catch (error) { + setRuntimeError(activeRecord.environmentId, error); + await removeConnection(activeRecord.environmentId).catch(() => false); + throw error; + } + })(); + + pendingSavedEnvironmentConnections.set(record.environmentId, nextConnection); + return await nextConnection.finally(() => { + if (pendingSavedEnvironmentConnections.get(record.environmentId) === nextConnection) { + pendingSavedEnvironmentConnections.delete(record.environmentId); + } + }); } async function syncSavedEnvironmentConnections( @@ -1063,8 +1297,22 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P setRuntimeConnecting(environmentId); try { + if (record.desktopSsh) { + await prepareSavedEnvironmentRecordForConnection(record); + } await connection.reconnect(); } catch (error) { + if (record.desktopSsh) { + const issued = await issueDesktopSshBearerSession( + getSavedEnvironmentRecord(environmentId) ?? record, + ); + await removeConnection(environmentId).catch(() => false); + await ensureSavedEnvironmentConnection(issued.record, { + bearerToken: issued.bearerToken, + role: issued.role, + }); + return; + } setRuntimeError(environmentId, error); throw error; } @@ -1081,33 +1329,40 @@ export async function addSavedEnvironment(input: { readonly pairingUrl?: string; readonly host?: string; readonly pairingCode?: string; + readonly desktopSsh?: DesktopSshEnvironmentTarget; }): Promise { const resolvedTarget = resolveRemotePairingTarget({ ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), ...(input.host !== undefined ? { host: input.host } : {}), ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), }); - const descriptor = await fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - }); + const descriptor = input.desktopSsh + ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) + : await fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolvedTarget.httpBaseUrl, + }); const environmentId = descriptor.environmentId; - - if (environmentConnections.has(environmentId)) { - throw new Error("This environment is already connected."); - } - - const bearerSession = await bootstrapRemoteBearerSession({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - credential: resolvedTarget.credential, - }); + const existingRecord = + getSavedEnvironmentRecord(environmentId) ?? + findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); + + const bearerSession = input.desktopSsh + ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) + : await bootstrapRemoteBearerSession({ + httpBaseUrl: resolvedTarget.httpBaseUrl, + credential: resolvedTarget.credential, + }); const record: SavedEnvironmentRecord = { environmentId, - label: input.label.trim() || descriptor.label, + label: input.label.trim() || existingRecord?.label || descriptor.label, wsBaseUrl: resolvedTarget.wsBaseUrl, httpBaseUrl: resolvedTarget.httpBaseUrl, - createdAt: isoNow(), + createdAt: existingRecord?.createdAt ?? isoNow(), lastConnectedAt: isoNow(), + ...((input.desktopSsh ?? existingRecord?.desktopSsh) + ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } + : {}), }; await persistSavedEnvironmentRecord(record); @@ -1116,26 +1371,37 @@ export async function addSavedEnvironment(input: { bearerSession.sessionToken, ); if (!didPersistBearerToken) { - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - listSavedEnvironmentRecords().map((entry) => ({ - environmentId: entry.environmentId, - label: entry.label, - httpBaseUrl: entry.httpBaseUrl, - wsBaseUrl: entry.wsBaseUrl, - createdAt: entry.createdAt, - lastConnectedAt: entry.lastConnectedAt, - })), - ); + await persistSavedEnvironmentRegistryRollback(); throw new Error("Unable to persist saved environment credentials."); } + useSavedEnvironmentRegistryStore.getState().upsert(record); + await removeConnection(environmentId).catch(() => false); await ensureSavedEnvironmentConnection(record, { bearerToken: bearerSession.sessionToken, role: bearerSession.role, }); - useSavedEnvironmentRegistryStore.getState().upsert(record); return record; } +export async function connectDesktopSshEnvironment( + target: DesktopSshEnvironmentTarget, + options?: { label?: string }, +): Promise { + const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { + issuePairingToken: true, + }); + if (!bootstrap.pairingToken) { + throw new Error("Desktop SSH launch did not return a pairing token."); + } + + return await addSavedEnvironment({ + label: options?.label?.trim() || bootstrap.target.alias, + host: bootstrap.httpBaseUrl, + pairingCode: bootstrap.pairingToken, + desktopSsh: bootstrap.target, + }); +} + export async function ensureEnvironmentConnectionBootstrapped( environmentId: EnvironmentId, ): Promise { @@ -1211,6 +1477,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); lastAppliedProjectionVersionByEnvironment.clear(); + pendingSavedEnvironmentConnections.clear(); for (const key of Array.from(threadDetailSubscriptions.keys())) { disposeThreadDetailSubscriptionByKey(key); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index de5f057875e..a02241315ff 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -169,6 +169,24 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg getSavedEnvironmentSecret: async () => null, setSavedEnvironmentSecret: async () => true, removeSavedEnvironmentSecret: async () => undefined, + discoverSshHosts: async () => [], + ensureSshEnvironment: async () => { + throw new Error("ensureSshEnvironment not implemented in test"); + }, + fetchSshEnvironmentDescriptor: async () => { + throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); + }, + bootstrapSshBearerSession: async () => { + throw new Error("bootstrapSshBearerSession not implemented in test"); + }, + fetchSshSessionState: async () => { + throw new Error("fetchSshSessionState not implemented in test"); + }, + issueSshWebSocketToken: async () => { + throw new Error("issueSshWebSocketToken not implemented in test"); + }, + onSshPasswordPrompt: () => () => undefined, + resolveSshPasswordPrompt: async () => undefined, getServerExposureState: async () => ({ mode: "local-only", endpointUrl: null, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index df62be29dfa..7c38d77853a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -145,6 +146,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1aeaf02a45b..fc0413e468b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -52,9 +52,11 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import type { EnvironmentId } from "./baseSchemas.ts"; +import type { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; -import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; +import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; @@ -126,6 +128,33 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } +export interface DesktopSshEnvironmentTarget { + alias: string; + hostname: string; + username: string | null; + port: number | null; +} + +export type DesktopSshHostSource = "ssh-config" | "known-hosts"; + +export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { + source: DesktopSshHostSource; +} + +export interface DesktopSshEnvironmentBootstrap { + target: DesktopSshEnvironmentTarget; + httpBaseUrl: string; + wsBaseUrl: string; + pairingToken: string | null; +} + +export interface DesktopSshPasswordPromptRequest { + requestId: string; + destination: string; + username: string | null; + prompt: string; +} + export interface PersistedSavedEnvironmentRecord { environmentId: EnvironmentId; label: string; @@ -133,6 +162,7 @@ export interface PersistedSavedEnvironmentRecord { httpBaseUrl: string; createdAt: string; lastConnectedAt: string | null; + desktopSsh?: DesktopSshEnvironmentTarget; } export type DesktopServerExposureMode = "local-only" | "network-accessible"; @@ -159,6 +189,23 @@ export interface DesktopBridge { getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + discoverSshHosts: () => Promise; + ensureSshEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { issuePairingToken?: boolean }, + ) => Promise; + fetchSshEnvironmentDescriptor: (httpBaseUrl: string) => Promise; + bootstrapSshBearerSession: ( + httpBaseUrl: string, + credential: string, + ) => Promise; + fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise; + issueSshWebSocketToken: ( + httpBaseUrl: string, + bearerToken: string, + ) => Promise; + onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; + resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; getAdvertisedEndpoints: () => Promise; From 213b99fb76e84899664d85a1bb9be16729daccf7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 14 Apr 2026 15:44:54 -0700 Subject: [PATCH 07/19] Harden remote SSH environment recovery - Validate SSH targets and known-host parsing more strictly - Retry desktop SSH session refresh on auth failures - Preserve saved registry state when bearer persistence fails --- apps/desktop/src/main.ts | 19 +- apps/desktop/src/sshEnvironment.test.ts | 22 +- apps/desktop/src/sshEnvironment.ts | 32 ++- .../settings/SettingsPanels.browser.tsx | 2 +- apps/web/src/environments/runtime/catalog.ts | 2 +- .../service.addSavedEnvironment.test.ts | 248 +++++++++++++++++- apps/web/src/environments/runtime/service.ts | 93 +++++-- 7 files changed, 359 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 30e811ed746..5d0514518a9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -511,9 +511,15 @@ function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarge return null; } + const alias = target.alias.trim(); + const hostname = target.hostname.trim(); + if (alias.length === 0 || hostname.length === 0) { + return null; + } + return { - alias: target.alias.trim(), - hostname: target.hostname.trim(), + alias, + hostname, username: target.username?.trim() || null, port: target.port ?? null, }; @@ -602,12 +608,11 @@ async function fetchLoopbackSshJson(input: { } if (!response.ok) { - throw new Error( - await readRemoteFetchErrorMessage( - response, - `SSH forwarded request failed (${response.status}).`, - ), + const message = await readRemoteFetchErrorMessage( + response, + `SSH forwarded request failed (${response.status}).`, ); + throw new Error(`[ssh_http:${response.status}] ${message}`); } return (await response.json()) as T; diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index fa5bca6a0db..d0f9c4c2d39 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -93,10 +93,19 @@ describe("sshEnvironment", () => { "|1|hashed|entry ssh-ed25519 CCCC", "@cert-authority *.example.com ssh-ed25519 DDDD", "[ssh.example.com]:2200 ssh-ed25519 EEEE", + "::1 ssh-ed25519 FFFF", + "2001:db8::1 ssh-ed25519 GGGG", "", ].join("\n"), ), - ).toEqual(["github.com", "gitlab-alias", "gitlab.com", "ssh.example.com"]); + ).toEqual([ + "::1", + "2001:db8::1", + "github.com", + "gitlab-alias", + "gitlab.com", + "ssh.example.com", + ]); }); it("parses resolved ssh config output into a target", () => { @@ -177,12 +186,23 @@ describe("sshEnvironment", () => { port: 2222, } as const; + expect(__test.buildRemoteLaunchScript()).toContain( + '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', + ); expect(__test.buildRemoteLaunchScript()).toContain('"$RUNNER_FILE" serve --host 127.0.0.1'); expect(__test.buildRemotePairingScript(target)).toContain( '"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json', ); }); + it("reads the last non-empty ssh output line", () => { + expect( + __test.getLastNonEmptyOutputLine( + ["Welcome to the host", "", '{"credential":"pairing-token"}', ""].join("\n"), + ), + ).toBe('{"credential":"pairing-token"}'); + }); + it("detects ssh auth failures from common permission denied messages", () => { expect( __test.isSshAuthFailure( diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index e197645fd99..c62473ba066 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -180,8 +180,10 @@ function parseKnownHostsHostnames(raw: string): ReadonlyArray { } for (const rawHost of hostField.split(",")) { - const host = - /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost)?.[1] ?? rawHost.replace(/:.*$/u, "").trim(); + const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); + const host = ( + bracketMatch?.[1] ?? (rawHost.includes(":") ? rawHost : rawHost.replace(/:.*$/u, "")) + ).trim(); if (host.length === 0 || hasSshPattern(host)) { continue; } @@ -640,7 +642,7 @@ NODE } REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" -if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then +if [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then : else REMOTE_PORT="$(pick_port)" @@ -653,6 +655,16 @@ printf '{"remotePort":%s}\\n' "$REMOTE_PORT" `.trimStart(); } +function getLastNonEmptyOutputLine(stdout: string): string | null { + return ( + stdout + .trim() + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .findLast((entry) => entry.length > 0) ?? null + ); +} + function buildRemoteT3RunnerScript(): string { return [ "#!/bin/sh", @@ -698,11 +710,7 @@ async function launchOrReuseRemoteServer( ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); - const line = result.stdout - .trim() - .split(/\r?\n/u) - .map((entry) => entry.trim()) - .findLast((entry) => entry.length > 0); + const line = getLastNonEmptyOutputLine(result.stdout); if (!line) { throw new Error("SSH launch did not return a remote port."); } @@ -725,7 +733,12 @@ async function issueRemotePairingToken( ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); - const parsed = JSON.parse(result.stdout) as { credential?: unknown }; + const line = getLastNonEmptyOutputLine(result.stdout); + if (!line) { + throw new Error("SSH pairing did not return a credential."); + } + + const parsed = JSON.parse(line) as { credential?: unknown }; if (typeof parsed.credential !== "string" || parsed.credential.trim().length === 0) { throw new Error("SSH pairing command returned an invalid credential."); } @@ -1006,6 +1019,7 @@ export const __test = { buildRemoteT3RunnerScript, buildSshAskpassHelperDescriptor, buildSshChildEnvironment, + getLastNonEmptyOutputLine, isSshAuthFailure, collectSshConfigAliasesFromFile, parseKnownHostsHostnames, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 6a700034054..336424a7044 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -114,7 +114,7 @@ const authAccessHarness = vi.hoisted(() => { }; }); -const mockConnectDesktopSshEnvironment = vi.fn(); +const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); vi.mock("../../environments/runtime", () => { const primaryConnection = { diff --git a/apps/web/src/environments/runtime/catalog.ts b/apps/web/src/environments/runtime/catalog.ts index 839e967ddfa..bf260ab5711 100644 --- a/apps/web/src/environments/runtime/catalog.ts +++ b/apps/web/src/environments/runtime/catalog.ts @@ -35,7 +35,7 @@ interface SavedEnvironmentRegistryStore extends SavedEnvironmentRegistryState { let savedEnvironmentRegistryHydrated = false; let savedEnvironmentRegistryHydrationPromise: Promise | null = null; -function toPersistedSavedEnvironmentRecord( +export function toPersistedSavedEnvironmentRecord( record: SavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { return { diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 54afb6c6f47..f1632087b95 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -1,18 +1,51 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; +let mockSavedRecords: Array> = []; + const mockResolveRemotePairingTarget = vi.fn(); const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); const mockBootstrapSshBearerSession = vi.fn(); +const mockFetchSshSessionState = vi.fn(); const mockPersistSavedEnvironmentRecord = vi.fn(); const mockWriteSavedEnvironmentBearerToken = vi.fn(); const mockSetSavedEnvironmentRegistry = vi.fn(); -const mockUpsert = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); +const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { + return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; +}); +const mockReadSavedEnvironmentBearerToken = vi.fn(); +const mockRemoveSavedEnvironmentBearerToken = vi.fn(); +const mockPatchRuntime = vi.fn(); +const mockClearRuntime = vi.fn(); +const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { + mockSavedRecords = Object.values(next.byId); +}); +const mockRemove = vi.fn((environmentId: EnvironmentId) => { + mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); +}); +const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { + mockSavedRecords = mockSavedRecords.map((record) => + record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, + ); +}); +const mockUpsert = vi.fn((record: Record) => { + mockSavedRecords = [ + ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), + record, + ]; +}); +const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); const mockEnsureSshEnvironment = vi.fn(); const mockFetchSshEnvironmentDescriptor = vi.fn(); +const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); const mockCreateEnvironmentConnection = vi.fn(); +const mockClientGetConfig = vi.fn(async () => ({ + environment: { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }, +})); vi.mock("../remote/target", () => ({ resolveRemotePairingTarget: mockResolveRemotePairingTarget, @@ -35,24 +68,27 @@ vi.mock("~/localApi", () => ({ })); vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: vi.fn(), + getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, hasSavedEnvironmentRegistryHydrated: vi.fn(), listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken: vi.fn(), - removeSavedEnvironmentBearerToken: vi.fn(), + readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, + toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore: { getState: () => ({ upsert: mockUpsert, - remove: vi.fn(), - markConnected: vi.fn(), + remove: mockRemove, + markConnected: mockMarkConnected, }), + setState: mockRegistrySetState, + subscribe: vi.fn(() => () => {}), }, useSavedEnvironmentRuntimeStore: { getState: () => ({ ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), + patch: mockPatchRuntime, + clear: mockClearRuntime, }), }, waitForSavedEnvironmentRegistryHydration: vi.fn(), @@ -63,16 +99,32 @@ vi.mock("./connection", () => ({ createEnvironmentConnection: mockCreateEnvironmentConnection, })); +vi.mock("../../rpc/wsRpcClient", () => ({ + createWsRpcClient: vi.fn(() => ({ + server: { + getConfig: mockClientGetConfig, + }, + orchestration: { + subscribeThread: vi.fn(() => () => {}), + }, + })), +})); + +vi.mock("../../rpc/wsTransport", () => ({ + WsTransport: vi.fn(), +})); + describe("addSavedEnvironment", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mockSavedRecords = []; vi.stubGlobal("window", { desktopBridge: { ensureSshEnvironment: mockEnsureSshEnvironment, fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, bootstrapSshBearerSession: mockBootstrapSshBearerSession, - fetchSshSessionState: vi.fn(), + fetchSshSessionState: mockFetchSshSessionState, issueSshWebSocketToken: vi.fn(), }, }); @@ -110,7 +162,12 @@ describe("addSavedEnvironment", () => { mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); - mockListSavedEnvironmentRecords.mockReturnValue([]); + mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); + mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); + mockFetchSshSessionState.mockResolvedValue({ + authenticated: true, + role: "owner", + }); mockCreateEnvironmentConnection.mockImplementation( (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ kind: "saved", @@ -122,6 +179,12 @@ describe("addSavedEnvironment", () => { dispose: async () => undefined, }), ); + mockClientGetConfig.mockResolvedValue({ + environment: { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }, + }); mockEnsureSshEnvironment.mockResolvedValue({ target: { alias: "devbox", @@ -157,6 +220,165 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + it("removes an older ssh record when the same target returns a new environment id", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockFetchSshEnvironmentDescriptor.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-2"), + label: "Remote environment", + }); + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Old ssh environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }), + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-2"), + }); + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: EnvironmentId.make("environment-2"), + }), + ); + expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); + expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + + await resetEnvironmentServiceForTests(); + }); + + it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockBootstrapSshBearerSession + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token", + role: "owner", + }) + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token-2", + role: "owner", + }); + mockFetchSshSessionState + .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) + .mockResolvedValueOnce({ + authenticated: true, + role: "owner", + }); + + const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await expect( + connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-1"), + }); + + expect(mockEnsureSshEnvironment).toHaveBeenCalled(); + expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); + expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); + + await resetEnvironmentServiceForTests(); + }); + + it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + + const connection = { + kind: "saved" as const, + environmentId: EnvironmentId.make("environment-1"), + knownEnvironment: { + environmentId: EnvironmentId.make("environment-1"), + }, + client: {}, + ensureBootstrapped: async () => undefined, + reconnect: vi.fn(async () => { + throw new Error("socket closed"); + }), + dispose: async () => undefined, + }; + mockCreateEnvironmentConnection.mockReturnValue(connection); + + const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await addSavedEnvironment({ + label: "Remote environment", + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }); + + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); + + await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( + "Unable to persist saved environment credentials.", + ); + + expect(mockPatchRuntime).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + expect.objectContaining({ + connectionState: "error", + lastError: "Unable to persist saved environment credentials.", + }), + ); + + await resetEnvironmentServiceForTests(); + }); + it("bootstraps a desktop ssh environment through the desktop bridge", async () => { mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); @@ -170,7 +392,9 @@ describe("addSavedEnvironment", () => { username: null, port: null, }), - ).rejects.toThrow(); + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-1"), + }); expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( { diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index be20cbd1fd5..aa81a47b904 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -47,6 +47,7 @@ import { readSavedEnvironmentBearerToken, removeSavedEnvironmentBearerToken, type SavedEnvironmentRecord, + toPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, waitForSavedEnvironmentRegistryHydration, @@ -112,6 +113,7 @@ let needsProviderInvalidation = false; const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; const NOOP = () => undefined; +const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; function compareAppliedProjectionVersion( left: { readonly sequence: number; readonly updatedAt: string | null }, @@ -495,16 +497,22 @@ function isoNow(): string { return new Date().toISOString(); } -function serializeSavedEnvironmentRecord(record: SavedEnvironmentRecord) { - return { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - } as const; +function readSshHttpErrorStatus(error: unknown): number | null { + if (!(error instanceof Error)) { + return null; + } + + const match = SSH_HTTP_STATUS_RE.exec(error.message); + if (!match) { + return null; + } + + const parsed = Number.parseInt(match[1] ?? "", 10); + return Number.isInteger(parsed) ? parsed : null; +} + +function isSshHttpAuthError(error: unknown, status: number): boolean { + return readSshHttpErrorStatus(error) === status; } function isDesktopSshTargetEqual( @@ -537,10 +545,28 @@ function findSavedEnvironmentRecordByDesktopSshTarget( ); } -async function persistSavedEnvironmentRegistryRollback(): Promise { +function buildSavedEnvironmentRegistryById( + records: ReadonlyArray, +): Record { + return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< + EnvironmentId, + SavedEnvironmentRecord + >; +} + +function snapshotSavedEnvironmentRegistry(): ReadonlyArray { + return listSavedEnvironmentRecords(); +} + +async function persistSavedEnvironmentRegistryRollback( + records: ReadonlyArray, +): Promise { await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - listSavedEnvironmentRecords().map((entry) => serializeSavedEnvironmentRecord(entry)), + records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), ); + useSavedEnvironmentRegistryStore.setState({ + byId: buildSavedEnvironmentRegistryById(records), + }); } async function resolveDesktopSshEnvironmentBootstrap( @@ -625,6 +651,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro readonly bearerToken: string; readonly role: AuthSessionRole | null; }> { + const registrySnapshot = snapshotSavedEnvironmentRegistry(); const prepared = await prepareSavedEnvironmentRecordForConnection(record, { issuePairingToken: true, }); @@ -641,7 +668,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro bearerSession.sessionToken, ); if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(); + await persistSavedEnvironmentRegistryRollback(registrySnapshot); throw new Error("Unable to persist saved environment credentials."); } @@ -1186,11 +1213,10 @@ async function ensureSavedEnvironmentConnection( options?.serverConfig ?? null, ); } catch (error) { - if ( - !record.desktopSsh || - !isRemoteEnvironmentAuthHttpError(error) || - error.status !== 401 - ) { + const isAuthError = activeRecord.desktopSsh + ? isSshHttpAuthError(error, 401) + : isRemoteEnvironmentAuthHttpError(error) && error.status === 401; + if (!isAuthError) { throw error; } @@ -1303,15 +1329,20 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P await connection.reconnect(); } catch (error) { if (record.desktopSsh) { - const issued = await issueDesktopSshBearerSession( - getSavedEnvironmentRecord(environmentId) ?? record, - ); - await removeConnection(environmentId).catch(() => false); - await ensureSavedEnvironmentConnection(issued.record, { - bearerToken: issued.bearerToken, - role: issued.role, - }); - return; + try { + const issued = await issueDesktopSshBearerSession( + getSavedEnvironmentRecord(environmentId) ?? record, + ); + await removeConnection(environmentId).catch(() => false); + await ensureSavedEnvironmentConnection(issued.record, { + bearerToken: issued.bearerToken, + role: issued.role, + }); + return; + } catch (recoveryError) { + setRuntimeError(environmentId, recoveryError); + throw recoveryError; + } } setRuntimeError(environmentId, error); throw error; @@ -1342,9 +1373,12 @@ export async function addSavedEnvironment(input: { httpBaseUrl: resolvedTarget.httpBaseUrl, }); const environmentId = descriptor.environmentId; + const registrySnapshot = snapshotSavedEnvironmentRegistry(); const existingRecord = getSavedEnvironmentRecord(environmentId) ?? findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); + const staleDesktopSshRecord = + existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; const bearerSession = input.desktopSsh ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) @@ -1371,10 +1405,13 @@ export async function addSavedEnvironment(input: { bearerSession.sessionToken, ); if (!didPersistBearerToken) { - await persistSavedEnvironmentRegistryRollback(); + await persistSavedEnvironmentRegistryRollback(registrySnapshot); throw new Error("Unable to persist saved environment credentials."); } useSavedEnvironmentRegistryStore.getState().upsert(record); + if (staleDesktopSshRecord) { + await removeSavedEnvironment(staleDesktopSshRecord.environmentId); + } await removeConnection(environmentId).catch(() => false); await ensureSavedEnvironmentConnection(record, { bearerToken: bearerSession.sessionToken, From 31eee770116c6b61867ae76a88728872087c19e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 14 Apr 2026 19:29:33 -0700 Subject: [PATCH 08/19] Respect desktop release channel for SSH bootstrap Co-authored-by: codex --- apps/desktop/src/main.ts | 8 +++- apps/desktop/src/sshEnvironment.test.ts | 27 +++++++++-- apps/desktop/src/sshEnvironment.ts | 60 ++++++++++++++++++++----- 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 5d0514518a9..24e1b5b48a2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -65,7 +65,7 @@ import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, } from "./serverExposure.ts"; -import { DesktopSshEnvironmentManager } from "./sshEnvironment.ts"; +import { DesktopSshEnvironmentManager, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -860,6 +860,12 @@ async function requestSshPasswordFromRenderer(input: { const desktopSshEnvironmentManager = new DesktopSshEnvironmentManager({ passwordProvider: requestSshPasswordFromRenderer, + resolveCliPackageSpec: () => + resolveRemoteT3CliPackageSpec({ + appVersion: app.getVersion(), + updateChannel: desktopSettings.updateChannel, + isDevelopment, + }), }); function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index d0f9c4c2d39..9fd8ab7830f 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -173,9 +173,24 @@ describe("sshEnvironment", () => { const script = __test.buildRemoteT3RunnerScript(); expect(script).toContain('exec t3 "$@"'); - expect(script).toContain('exec npx --yes t3 "$@"'); - expect(script).toContain('exec npm exec --yes t3 -- "$@"'); - expect(script).toContain("could not find npx or npm on PATH"); + expect(script).toContain('exec npx --yes t3@latest "$@"'); + expect(script).toContain('exec npm exec --yes t3@latest -- "$@"'); + expect(script).toContain("could not install t3@latest"); + }); + + it("resolves the remote t3 package spec from the desktop release channel", () => { + expect( + __test.resolveRemoteT3CliPackageSpec({ + appVersion: "0.0.17", + updateChannel: "latest", + }), + ).toBe("t3@0.0.17"); + expect( + __test.resolveRemoteT3CliPackageSpec({ + appVersion: "0.0.17-nightly.20260415.44", + updateChannel: "nightly", + }), + ).toBe("t3@nightly"); }); it("uses the remote t3 runner for launch and pairing scripts", () => { @@ -190,9 +205,15 @@ describe("sshEnvironment", () => { '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', ); expect(__test.buildRemoteLaunchScript()).toContain('"$RUNNER_FILE" serve --host 127.0.0.1'); + expect(__test.buildRemoteLaunchScript({ packageSpec: "t3@nightly" })).toContain( + 'exec npx --yes t3@nightly "$@"', + ); expect(__test.buildRemotePairingScript(target)).toContain( '"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json', ); + expect(__test.buildRemotePairingScript(target, { packageSpec: "t3@nightly" })).toContain( + 'exec npm exec --yes t3@nightly -- "$@"', + ); }); it("reads the last non-empty ssh output line", () => { diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index c62473ba066..7144d8b8bd5 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -9,6 +9,7 @@ import type { DesktopDiscoveredSshHost, DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentTarget, + DesktopUpdateChannel, } from "@t3tools/contracts"; import { waitForHttpReady } from "./backendReadiness"; @@ -18,6 +19,7 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const SSH_READY_TIMEOUT_MS = 20_000; +const STABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; interface SshTunnelEntry { readonly key: string; @@ -60,6 +62,7 @@ interface DesktopSshPasswordRequest { interface DesktopSshEnvironmentManagerOptions { readonly passwordProvider?: (request: DesktopSshPasswordRequest) => Promise; + readonly resolveCliPackageSpec?: () => string; } const NO_HOSTS = [] as const; @@ -591,8 +594,8 @@ async function resolveDesktopSshTarget(alias: string): Promise/dev/null 2>&1; then", - ' exec npx --yes t3 "$@"', + ` exec npx --yes ${packageSpec} "$@"`, "fi", "if command -v npm >/dev/null 2>&1; then", - ' exec npm exec --yes t3 -- "$@"', + ` exec npm exec --yes ${packageSpec} -- "$@"`, "fi", - "printf 'Remote host is missing the t3 CLI and could not find npx or npm on PATH.\\n' >&2", + `printf 'Remote host is missing the t3 CLI and could not install ${packageSpec} because npx and npm are unavailable on PATH.\\n' >&2`, "exit 1", ].join("\n"); } -function buildRemotePairingScript(target: DesktopSshEnvironmentTarget): string { - const runnerScript = buildRemoteT3RunnerScript(); +function buildRemotePairingScript( + target: DesktopSshEnvironmentTarget, + input?: { readonly packageSpec?: string }, +): string { + const runnerScript = buildRemoteT3RunnerScript(input); return ` set -eu STATE_DIR="$HOME/.t3/ssh-launch/${remoteStateKey(target)}" @@ -702,10 +726,11 @@ chmod 700 "$RUNNER_FILE" async function launchOrReuseRemoteServer( target: DesktopSshEnvironmentTarget, input?: SshAuthOptions, + runner?: { readonly packageSpec?: string }, ): Promise { const result = await runSshCommand(target, { remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], - stdin: buildRemoteLaunchScript(), + stdin: buildRemoteLaunchScript(runner), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -725,10 +750,11 @@ async function launchOrReuseRemoteServer( async function issueRemotePairingToken( target: DesktopSshEnvironmentTarget, input?: SshAuthOptions, + runner?: { readonly packageSpec?: string }, ): Promise { const result = await runSshCommand(target, { remoteCommandArgs: ["sh", "-s"], - stdin: buildRemotePairingScript(target), + stdin: buildRemotePairingScript(target, runner), ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), @@ -898,6 +924,7 @@ export class DesktopSshEnvironmentManager { ): Promise { const resolvedTarget = await resolveDesktopSshTarget(target.alias || target.hostname); const key = targetConnectionKey(resolvedTarget); + const packageSpec = this.options.resolveCliPackageSpec?.(); let entry = this.tunnels.get(key) ?? null; if (entry !== null) { @@ -912,7 +939,11 @@ export class DesktopSshEnvironmentManager { if (entry === null) { const remotePort = await this.runWithSshAuth(key, resolvedTarget, (authOptions) => - launchOrReuseRemoteServer(resolvedTarget, authOptions), + launchOrReuseRemoteServer( + resolvedTarget, + authOptions, + packageSpec === undefined ? undefined : { packageSpec }, + ), ); const localPort = await findAvailableLocalPort(); const httpBaseUrl = `http://127.0.0.1:${localPort}/`; @@ -993,7 +1024,11 @@ export class DesktopSshEnvironmentManager { const pairingToken = options?.issuePairingToken ? await this.runWithSshAuth(key, entry.target, (authOptions) => - issueRemotePairingToken(entry.target, authOptions), + issueRemotePairingToken( + entry.target, + authOptions, + packageSpec === undefined ? undefined : { packageSpec }, + ), ) : null; @@ -1017,6 +1052,7 @@ export const __test = { buildRemoteLaunchScript, buildRemotePairingScript, buildRemoteT3RunnerScript, + resolveRemoteT3CliPackageSpec, buildSshAskpassHelperDescriptor, buildSshChildEnvironment, getLastNonEmptyOutputLine, From 4918dac8b4fd77f589b674cc77aa4c36174cfce9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 14 Apr 2026 19:57:23 -0700 Subject: [PATCH 09/19] Pin nightly SSH bootstrap to desktop version Co-authored-by: codex --- apps/desktop/src/sshEnvironment.test.ts | 7 +++++++ apps/desktop/src/sshEnvironment.ts | 10 +++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index 9fd8ab7830f..6d2ec174ec7 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -190,6 +190,13 @@ describe("sshEnvironment", () => { appVersion: "0.0.17-nightly.20260415.44", updateChannel: "nightly", }), + ).toBe("t3@0.0.17-nightly.20260415.44"); + expect( + __test.resolveRemoteT3CliPackageSpec({ + appVersion: "0.0.0-dev", + updateChannel: "nightly", + isDevelopment: true, + }), ).toBe("t3@nightly"); }); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 7144d8b8bd5..d81c91a1735 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -19,7 +19,7 @@ const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_ASKPASS_DIR_NAME = "t3code-ssh-askpass"; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const SSH_READY_TIMEOUT_MS = 20_000; -const STABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+$/u; +const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; interface SshTunnelEntry { readonly key: string; @@ -673,16 +673,12 @@ export function resolveRemoteT3CliPackageSpec(input: { readonly updateChannel: DesktopUpdateChannel; readonly isDevelopment?: boolean; }): string { - if (input.updateChannel === "nightly") { - return "t3@nightly"; - } - const appVersion = input.appVersion.trim(); - if (!input.isDevelopment && STABLE_T3_VERSION_PATTERN.test(appVersion)) { + if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { return `t3@${appVersion}`; } - return "t3@latest"; + return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; } function buildRemoteT3RunnerScript(input?: { readonly packageSpec?: string }): string { From a1bdafda777b225389ee2fd8cf5f62dca097ab16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 13:38:58 -0700 Subject: [PATCH 10/19] Address SSH launch review feedback Co-authored-by: codex --- apps/desktop/src/sshEnvironment.test.ts | 61 ++++++- apps/desktop/src/sshEnvironment.ts | 153 +++++++++++++----- .../service.addSavedEnvironment.test.ts | 137 +++++++++++++++- apps/web/src/environments/runtime/service.ts | 46 ++++-- 4 files changed, 345 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index 6d2ec174ec7..e26cc7da820 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -1,8 +1,9 @@ import * as FS from "node:fs"; +import { EventEmitter } from "node:events"; import * as OS from "node:os"; import * as Path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { __test, discoverDesktopSshHosts } from "./sshEnvironment"; @@ -93,6 +94,7 @@ describe("sshEnvironment", () => { "|1|hashed|entry ssh-ed25519 CCCC", "@cert-authority *.example.com ssh-ed25519 DDDD", "[ssh.example.com]:2200 ssh-ed25519 EEEE", + "port.example.com:22 ssh-ed25519 HHHH", "::1 ssh-ed25519 FFFF", "2001:db8::1 ssh-ed25519 GGGG", "", @@ -104,10 +106,17 @@ describe("sshEnvironment", () => { "github.com", "gitlab-alias", "gitlab.com", + "port.example.com", "ssh.example.com", ]); }); + it("expands tilde-prefixed ssh config include patterns", () => { + expect( + __test.resolveSshConfigIncludePattern("~/.ssh/config.d/*.conf", "/tmp/project", "/tmp/home"), + ).toBe("/tmp/home/.ssh/config.d/*.conf"); + }); + it("parses resolved ssh config output into a target", () => { expect( __test.parseSshResolveOutput( @@ -240,5 +249,55 @@ describe("sshEnvironment", () => { ), ).toBe(true); expect(__test.isSshAuthFailure(new Error("Connection timed out"))).toBe(false); + expect(__test.isSshAuthFailure(new Error("mkdir: Permission denied"))).toBe(false); + }); + + it("settles tunnel shutdown if the child exits before the exit listener attaches", async () => { + vi.useFakeTimers(); + + class RaceChildProcess extends EventEmitter { + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + + override once(eventName: string | symbol, listener: (...args: any[]) => void): this { + if (eventName === "exit") { + this.exitCode = 0; + return this; + } + return super.once(eventName, listener); + } + + kill(): boolean { + return true; + } + } + + try { + const child = new RaceChildProcess(); + const stopPromise = __test + .stopTunnel({ + key: "devbox", + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + remotePort: 3773, + localPort: 3774, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + process: child as never, + }) + .then(() => "resolved"); + + await vi.runAllTimersAsync(); + + await expect(Promise.race([stopPromise, Promise.resolve("pending")])).resolves.toBe( + "resolved", + ); + } finally { + vi.useRealTimers(); + } }); }); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index d81c91a1735..ed20e840baa 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -80,6 +80,21 @@ function splitDirectiveArgs(value: string): ReadonlyArray { .filter((entry) => entry.length > 0); } +function expandHomePath(input: string, homeDir: string = OS.homedir()): string { + return input.replace(/^~(?=$|\/|\\)/u, homeDir); +} + +function resolveSshConfigIncludePattern( + includePattern: string, + directory: string, + homeDir: string = OS.homedir(), +): string { + const expandedPattern = expandHomePath(includePattern, homeDir); + return Path.isAbsolute(expandedPattern) + ? expandedPattern + : Path.resolve(directory, expandedPattern); +} + function hasSshPattern(value: string): boolean { return value.includes("*") || value.includes("?") || value.startsWith("!"); } @@ -138,9 +153,7 @@ function collectSshConfigAliasesFromFile( const normalizedDirective = directive.toLowerCase(); if (normalizedDirective === "include") { for (const includePattern of rawArgs) { - const resolvedPattern = Path.isAbsolute(includePattern) - ? includePattern - : Path.resolve(directory, includePattern); + const resolvedPattern = resolveSshConfigIncludePattern(includePattern, directory); for (const includedPath of expandGlob(resolvedPattern)) { for (const alias of collectSshConfigAliasesFromFile(includedPath, visited)) { aliases.add(alias); @@ -165,6 +178,21 @@ function collectSshConfigAliasesFromFile( return [...aliases].toSorted((left, right) => left.localeCompare(right)); } +function normalizeKnownHostsHostname(rawHost: string): string { + const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); + if (bracketMatch?.[1]) { + return bracketMatch[1]; + } + + if (!rawHost.includes(":")) { + return rawHost; + } + + const firstColonIndex = rawHost.indexOf(":"); + const lastColonIndex = rawHost.lastIndexOf(":"); + return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost; +} + function parseKnownHostsHostnames(raw: string): ReadonlyArray { const hostnames = new Set(); @@ -183,10 +211,7 @@ function parseKnownHostsHostnames(raw: string): ReadonlyArray { } for (const rawHost of hostField.split(",")) { - const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost); - const host = ( - bracketMatch?.[1] ?? (rawHost.includes(":") ? rawHost : rawHost.replace(/:.*$/u, "")) - ).trim(); + const host = normalizeKnownHostsHostname(rawHost).trim(); if (host.length === 0 || hasSshPattern(host)) { continue; } @@ -482,8 +507,13 @@ function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): stri function isSshAuthFailure(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); - return /permission denied|authentication failed|keyboard-interactive/u.test( - message.toLowerCase(), + const normalized = message.toLowerCase(); + return ( + /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]+\)/u.test( + normalized, + ) || + /authentication failed/u.test(normalized) || + /too many authentication failures/u.test(normalized) ); } @@ -776,6 +806,7 @@ async function stopTunnel(entry: SshTunnelEntry): Promise { await new Promise((resolve) => { let settled = false; let forceKillTimer: ReturnType | null = null; + let hardStopTimer: ReturnType | null = null; const settle = () => { if (settled) { @@ -786,6 +817,9 @@ async function stopTunnel(entry: SshTunnelEntry): Promise { if (forceKillTimer) { clearTimeout(forceKillTimer); } + if (hardStopTimer) { + clearTimeout(hardStopTimer); + } resolve(); }; @@ -794,11 +828,22 @@ async function stopTunnel(entry: SshTunnelEntry): Promise { }; child.once("exit", onExit); - child.kill("SIGTERM"); + if (child.exitCode !== null || child.signalCode !== null) { + settle(); + return; + } + if (!child.kill("SIGTERM")) { + settle(); + return; + } forceKillTimer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); } + hardStopTimer = setTimeout(() => { + settle(); + }, 1_000); + hardStopTimer.unref(); }, TUNNEL_SHUTDOWN_TIMEOUT_MS); forceKillTimer.unref(); }); @@ -840,10 +885,17 @@ export async function discoverDesktopSshHosts(input?: { export class DesktopSshEnvironmentManager { private readonly tunnels = new Map(); + private readonly pendingTunnelEntries = new Map>(); private readonly authSecrets = new Map(); constructor(private readonly options: DesktopSshEnvironmentManagerOptions = {}) {} + private deleteTunnelIfCurrent(entry: SshTunnelEntry): void { + if (this.tunnels.get(entry.key) === entry) { + this.tunnels.delete(entry.key); + } + } + private async promptForPassword( target: DesktopSshEnvironmentTarget, attempt: number, @@ -921,19 +973,50 @@ export class DesktopSshEnvironmentManager { const resolvedTarget = await resolveDesktopSshTarget(target.alias || target.hostname); const key = targetConnectionKey(resolvedTarget); const packageSpec = this.options.resolveCliPackageSpec?.(); + const entry = await this.ensureTunnelEntry(key, resolvedTarget, packageSpec); + + const pairingToken = options?.issuePairingToken + ? await this.runWithSshAuth(key, entry.target, (authOptions) => + issueRemotePairingToken( + entry.target, + authOptions, + packageSpec === undefined ? undefined : { packageSpec }, + ), + ) + : null; + + return { + target: entry.target, + httpBaseUrl: entry.httpBaseUrl, + wsBaseUrl: entry.wsBaseUrl, + pairingToken, + }; + } + + private async ensureTunnelEntry( + key: string, + resolvedTarget: DesktopSshEnvironmentTarget, + packageSpec?: string, + ): Promise { let entry = this.tunnels.get(key) ?? null; if (entry !== null) { try { await waitForHttpReady(entry.httpBaseUrl, { timeoutMs: 2_000 }); + return entry; } catch { await stopTunnel(entry).catch(() => undefined); - this.tunnels.delete(key); + this.deleteTunnelIfCurrent(entry); entry = null; } } - if (entry === null) { + const pending = this.pendingTunnelEntries.get(key); + if (pending) { + return await pending; + } + + const nextEntry = (async () => { const remotePort = await this.runWithSshAuth(key, resolvedTarget, (authOptions) => launchOrReuseRemoteServer( resolvedTarget, @@ -944,7 +1027,7 @@ export class DesktopSshEnvironmentManager { const localPort = await findAvailableLocalPort(); const httpBaseUrl = `http://127.0.0.1:${localPort}/`; const wsBaseUrl = `ws://127.0.0.1:${localPort}/`; - entry = await this.runWithSshAuth(key, resolvedTarget, async (authOptions) => { + return await this.runWithSshAuth(key, resolvedTarget, async (authOptions) => { const process = ChildProcess.spawn( "ssh", [ @@ -972,7 +1055,7 @@ export class DesktopSshEnvironmentManager { stdio: "pipe", }, ); - const nextEntry: SshTunnelEntry = { + const tunnelEntry: SshTunnelEntry = { key, target: resolvedTarget, remotePort, @@ -988,11 +1071,11 @@ export class DesktopSshEnvironmentManager { stderr += chunk; }); process.once("error", (error) => { - this.tunnels.delete(key); + this.deleteTunnelIfCurrent(tunnelEntry); reject(error); }); process.once("exit", (code) => { - this.tunnels.delete(key); + this.deleteTunnelIfCurrent(tunnelEntry); reject( new Error( normalizeSshErrorMessage( @@ -1006,39 +1089,29 @@ export class DesktopSshEnvironmentManager { .then(() => resolve()) .catch((error) => reject(error)); }); - this.tunnels.set(key, nextEntry); + this.tunnels.set(key, tunnelEntry); try { await tunnelReady; - return nextEntry; + return tunnelEntry; } catch (error) { - await stopTunnel(nextEntry).catch(() => undefined); - this.tunnels.delete(key); + await stopTunnel(tunnelEntry).catch(() => undefined); + this.deleteTunnelIfCurrent(tunnelEntry); throw error; } }); - } - - const pairingToken = options?.issuePairingToken - ? await this.runWithSshAuth(key, entry.target, (authOptions) => - issueRemotePairingToken( - entry.target, - authOptions, - packageSpec === undefined ? undefined : { packageSpec }, - ), - ) - : null; - - return { - target: entry.target, - httpBaseUrl: entry.httpBaseUrl, - wsBaseUrl: entry.wsBaseUrl, - pairingToken, - }; + })(); + this.pendingTunnelEntries.set(key, nextEntry); + return await nextEntry.finally(() => { + if (this.pendingTunnelEntries.get(key) === nextEntry) { + this.pendingTunnelEntries.delete(key); + } + }); } async dispose(): Promise { const entries = [...this.tunnels.values()]; this.tunnels.clear(); + this.pendingTunnelEntries.clear(); await Promise.all(entries.map((entry) => stopTunnel(entry).catch(() => undefined))); } } @@ -1054,6 +1127,10 @@ export const __test = { getLastNonEmptyOutputLine, isSshAuthFailure, collectSshConfigAliasesFromFile, + expandHomePath, + normalizeKnownHostsHostname, parseKnownHostsHostnames, parseSshResolveOutput, + resolveSshConfigIncludePattern, + stopTunnel, }; diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index f1632087b95..a6ee6c5d222 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -6,6 +6,9 @@ let mockSavedRecords: Array> = []; const mockResolveRemotePairingTarget = vi.fn(); const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); +const mockFetchRemoteSessionState = vi.fn(); +const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); const mockBootstrapSshBearerSession = vi.fn(); const mockFetchSshSessionState = vi.fn(); const mockPersistSavedEnvironmentRecord = vi.fn(); @@ -54,9 +57,9 @@ vi.mock("../remote/target", () => ({ vi.mock("../remote/api", () => ({ bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, - fetchRemoteSessionState: vi.fn(), - isRemoteEnvironmentAuthHttpError: vi.fn(() => false), - resolveRemoteWebSocketConnectionUrl: vi.fn(), + fetchRemoteSessionState: mockFetchRemoteSessionState, + isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, + resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, })); vi.mock("~/localApi", () => ({ @@ -151,6 +154,14 @@ describe("addSavedEnvironment", () => { sessionToken: "bearer-token", role: "owner", }); + mockFetchRemoteSessionState.mockResolvedValue({ + authenticated: true, + role: "owner", + }); + mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); + mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( + "wss://remote.example.com/?wsToken=remote-token", + ); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ environmentId: EnvironmentId.make("environment-1"), label: "Remote environment", @@ -220,6 +231,37 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + it("restores unrelated saved environments when credential persistence rollback runs", async () => { + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-existing"), + label: "Existing environment", + httpBaseUrl: "https://existing.example.com/", + wsBaseUrl: "wss://existing.example.com/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + }, + ]; + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("Unable to persist saved environment credentials."); + + expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ + expect.objectContaining({ + environmentId: EnvironmentId.make("environment-existing"), + }), + ]); + + await resetEnvironmentServiceForTests(); + }); + it("removes an older ssh record when the same target returns a new environment id", async () => { mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); mockFetchSshEnvironmentDescriptor.mockResolvedValue({ @@ -313,6 +355,95 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + const authError = { + status: 401, + message: "Unauthorized", + }; + mockFetchRemoteSessionState.mockRejectedValueOnce(authError); + mockIsRemoteEnvironmentAuthHttpError.mockImplementation( + (error: unknown) => error === authError, + ); + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("Saved environment credential expired. Pair it again."); + + expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); + expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); + expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + + await resetEnvironmentServiceForTests(); + }); + + it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockBootstrapSshBearerSession + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token", + role: "owner", + }) + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token-2", + role: "owner", + }); + mockFetchSshSessionState + .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) + .mockResolvedValueOnce({ + authenticated: true, + role: "owner", + }); + + const createdConnections: Array<{ + readonly environmentId: EnvironmentId; + readonly dispose: ReturnType; + }> = []; + mockCreateEnvironmentConnection.mockImplementation( + (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { + const connection = { + kind: "saved" as const, + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: vi.fn(async () => undefined), + }; + createdConnections.push(connection); + return connection; + }, + ); + + const { + connectDesktopSshEnvironment, + listEnvironmentConnections, + resetEnvironmentServiceForTests, + } = await import("./service"); + + await connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }); + + expect(createdConnections).toHaveLength(2); + expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); + expect(listEnvironmentConnections()).toHaveLength(1); + expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); + + await resetEnvironmentServiceForTests(); + }); + it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index aa81a47b904..1ff8b662afd 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -554,18 +554,36 @@ function buildSavedEnvironmentRegistryById( >; } -function snapshotSavedEnvironmentRegistry(): ReadonlyArray { - return listSavedEnvironmentRecords(); +type SavedEnvironmentRegistrySnapshot = ReadonlyMap; + +function snapshotSavedEnvironmentRegistry( + environmentIds: ReadonlyArray, +): SavedEnvironmentRegistrySnapshot { + return new Map( + environmentIds.map((environmentId) => [ + environmentId, + getSavedEnvironmentRecord(environmentId) ?? null, + ]), + ); } async function persistSavedEnvironmentRegistryRollback( - records: ReadonlyArray, + snapshot: SavedEnvironmentRegistrySnapshot, ): Promise { + const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); + for (const [environmentId, record] of snapshot) { + if (record) { + byId[environmentId] = record; + continue; + } + delete byId[environmentId]; + } + const records = Object.values(byId); await ensureLocalApi().persistence.setSavedEnvironmentRegistry( records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), ); useSavedEnvironmentRegistryStore.setState({ - byId: buildSavedEnvironmentRegistryById(records), + byId, }); } @@ -651,7 +669,7 @@ async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Pro readonly bearerToken: string; readonly role: AuthSessionRole | null; }> { - const registrySnapshot = snapshotSavedEnvironmentRegistry(); + const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); const prepared = await prepareSavedEnvironmentRecordForConnection(record, { issuePairingToken: true, }); @@ -1201,8 +1219,6 @@ async function ensureSavedEnvironmentConnection( ...createEnvironmentConnectionHandlers(), }); - registerConnection(connection); - try { try { await refreshSavedEnvironmentMetadata( @@ -1219,12 +1235,18 @@ async function ensureSavedEnvironmentConnection( if (!isAuthError) { throw error; } + if (!activeRecord.desktopSsh) { + await removeSavedEnvironmentBearerToken(activeRecord.environmentId); + throw new Error("Saved environment credential expired. Pair it again.", { + cause: error, + }); + } const issued = await issueDesktopSshBearerSession(activeRecord); activeRecord = issued.record; bearerToken = issued.bearerToken; roleHint = issued.role; - await removeConnection(activeRecord.environmentId).catch(() => false); + await connection.dispose().catch(() => undefined); pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); return await ensureSavedEnvironmentConnection(activeRecord, { bearerToken, @@ -1232,10 +1254,14 @@ async function ensureSavedEnvironmentConnection( serverConfig: options?.serverConfig ?? null, }); } + registerConnection(connection); return connection; } catch (error) { setRuntimeError(activeRecord.environmentId, error); - await removeConnection(activeRecord.environmentId).catch(() => false); + const removed = await removeConnection(activeRecord.environmentId).catch(() => false); + if (!removed) { + await connection.dispose().catch(() => undefined); + } throw error; } })(); @@ -1373,7 +1399,7 @@ export async function addSavedEnvironment(input: { httpBaseUrl: resolvedTarget.httpBaseUrl, }); const environmentId = descriptor.environmentId; - const registrySnapshot = snapshotSavedEnvironmentRegistry(); + const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); const existingRecord = getSavedEnvironmentRecord(environmentId) ?? findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); From 75222b7e96c1b22df2d845182d33b1b628af63a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 14:45:08 -0700 Subject: [PATCH 11/19] Use nightly remote T3 CLI in development - Resolve dev remote package specs to `t3@nightly` - Cover the dev fallback in sshEnvironment tests --- apps/desktop/src/sshEnvironment.test.ts | 7 +++++++ apps/desktop/src/sshEnvironment.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index e26cc7da820..2b4f0cadc88 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -207,6 +207,13 @@ describe("sshEnvironment", () => { isDevelopment: true, }), ).toBe("t3@nightly"); + expect( + __test.resolveRemoteT3CliPackageSpec({ + appVersion: "0.0.0-dev", + updateChannel: "latest", + isDevelopment: true, + }), + ).toBe("t3@nightly"); }); it("uses the remote t3 runner for launch and pairing scripts", () => { diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index ed20e840baa..249f8bbda3a 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -708,6 +708,10 @@ export function resolveRemoteT3CliPackageSpec(input: { return `t3@${appVersion}`; } + if (input.isDevelopment) { + return "t3@nightly"; + } + return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; } From a6ae32e3671b29ee1cf6be86cae3f4fd6b777d26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 15:52:00 -0700 Subject: [PATCH 12/19] Improve SSH launch diagnostics - surface stdout when remote launch or pairing fails - report parse errors and invalid remote port or credential values --- apps/desktop/src/sshEnvironment.ts | 46 ++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 249f8bbda3a..bdca2f7cda2 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -678,7 +678,11 @@ REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" if [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then : else - REMOTE_PORT="$(pick_port)" + REMOTE_PORT="$(pick_port)" || true + if [ -z "$REMOTE_PORT" ]; then + printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 + exit 1 + fi nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & REMOTE_PID="$!" printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" @@ -767,12 +771,24 @@ async function launchOrReuseRemoteServer( }); const line = getLastNonEmptyOutputLine(result.stdout); if (!line) { - throw new Error("SSH launch did not return a remote port."); + throw new Error( + `SSH launch did not return a remote port. stdout=${JSON.stringify(result.stdout)}`, + ); } - const parsed = JSON.parse(line) as { remotePort?: unknown }; + let parsed: { remotePort?: unknown }; + try { + parsed = JSON.parse(line) as { remotePort?: unknown }; + } catch (cause) { + throw new Error( + `SSH launch returned unparseable output. line=${JSON.stringify(line)} stdout=${JSON.stringify(result.stdout)}`, + { cause }, + ); + } if (typeof parsed.remotePort !== "number" || !Number.isInteger(parsed.remotePort)) { - throw new Error("SSH launch returned an invalid remote port."); + throw new Error( + `SSH launch returned an invalid remote port. parsed=${JSON.stringify(parsed)} stdout=${JSON.stringify(result.stdout)}`, + ); } return parsed.remotePort; } @@ -789,14 +805,26 @@ async function issueRemotePairingToken( ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); - const line = getLastNonEmptyOutputLine(result.stdout); - if (!line) { - throw new Error("SSH pairing did not return a credential."); + const stdout = result.stdout.trim(); + if (!stdout) { + throw new Error( + `SSH pairing did not return a credential. stdout=${JSON.stringify(result.stdout)}`, + ); } - const parsed = JSON.parse(line) as { credential?: unknown }; + let parsed: { credential?: unknown }; + try { + parsed = JSON.parse(stdout) as { credential?: unknown }; + } catch (cause) { + throw new Error( + `SSH pairing returned unparseable output. stdout=${JSON.stringify(result.stdout)}`, + { cause }, + ); + } if (typeof parsed.credential !== "string" || parsed.credential.trim().length === 0) { - throw new Error("SSH pairing command returned an invalid credential."); + throw new Error( + `SSH pairing command returned an invalid credential. parsed=${JSON.stringify(parsed)} stdout=${JSON.stringify(result.stdout)}`, + ); } return parsed.credential; } From c99c0db07dee0964ef7c786496e8824aefe78daf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Apr 2026 17:06:00 -0700 Subject: [PATCH 13/19] Tighten SSH setup dialog scrolling - Add a capped scroll area for discovered SSH hosts - Keep the manual SSH form always visible and simplify the dialog layout - Ensure the scroll area viewport respects inherited max height --- .../settings/ConnectionsSettings.tsx | 192 ++++++++---------- apps/web/src/components/ui/scroll-area.tsx | 2 +- 2 files changed, 88 insertions(+), 106 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 061feb4add5..848c8370933 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -32,7 +32,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { ScrollArea } from "../ui/scroll-area"; import { AlertDialog, AlertDialogClose, @@ -1175,7 +1175,6 @@ export function ConnectionsSettings() { const [savedBackendSshHost, setSavedBackendSshHost] = useState(""); const [savedBackendSshUsername, setSavedBackendSshUsername] = useState(""); const [savedBackendSshPort, setSavedBackendSshPort] = useState(""); - const [isManualSshFormOpen, setIsManualSshFormOpen] = useState(false); const [savedBackendError, setSavedBackendError] = useState(null); const [isAddingSavedBackend, setIsAddingSavedBackend] = useState(false); const [reconnectingSavedEnvironmentId, setReconnectingSavedEnvironmentId] = @@ -1322,7 +1321,7 @@ export function ConnectionsSettings() { setSavedBackendSshHost(""); setSavedBackendSshUsername(""); setSavedBackendSshPort(""); - setIsManualSshFormOpen(false); + setAddBackendDialogOpen(false); toastManager.add({ type: "success", @@ -1331,7 +1330,7 @@ export function ConnectionsSettings() { }); } catch (error) { const message = error instanceof Error ? error.message : "Failed to connect SSH host."; - setIsManualSshFormOpen(true); + setSavedBackendError(message); toastManager.add({ type: "error", @@ -1363,7 +1362,6 @@ export function ConnectionsSettings() { setSavedBackendSshHost(""); setSavedBackendSshUsername(""); setSavedBackendSshPort(""); - setIsManualSshFormOpen(false); setAddBackendDialogOpen(false); toastManager.add({ type: "success", @@ -1875,7 +1873,6 @@ export function ConnectionsSettings() { setAddBackendDialogOpen(open); if (!open) { setSavedBackendError(null); - setIsManualSshFormOpen(false); } }} > @@ -1887,7 +1884,7 @@ export function ConnectionsSettings() { } /> - + Add Environment Pair another environment to this client. @@ -2046,124 +2043,109 @@ export function ConnectionsSettings() { {discoveredSshHostsError ? (

{discoveredSshHostsError}

) : null} -
- {discoveredSshHosts.map((target) => ( - void handleConnectSshHost(nextTarget)} - /> - ))} - {hasLoadedDiscoveredSshHosts && - !isLoadingDiscoveredSshHosts && - discoveredSshHosts.length === 0 ? ( -
-

- No SSH hosts were discovered from ~/.ssh/config or{" "} - known_hosts. -

-
- ) : null} -
+ +
+ {discoveredSshHosts.map((target) => ( + void handleConnectSshHost(nextTarget)} + /> + ))} + {hasLoadedDiscoveredSshHosts && + !isLoadingDiscoveredSshHosts && + discoveredSshHosts.length === 0 ? ( +
+

+ No SSH hosts were discovered from ~/.ssh/config or{" "} + known_hosts. +

+
+ ) : null} +
+
{savedBackendError ? (

{savedBackendError}

) : null} - - -
-

- Enter a host manually -

-

- Use an alias or enter host, username, and port directly. -

-
- -
- -
+
+

+ Enter a host manually +

+

+ Use an alias or enter host, username, and port directly. +

+
+ + +
-
- - -
- - Uses your existing SSH keys, agent, and config. Password and - keyboard-interactive prompts open through your system SSH dialog when - needed. - -
- - + + Uses your existing SSH keys, agent, and config. Password and + keyboard-interactive prompts open through your system SSH dialog when + needed. + + +
+
)} {savedBackendMode !== "ssh" && savedBackendError ? ( diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index c8aa6dc96db..88fd3b7e975 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -23,7 +23,7 @@ function ScrollArea({ > Date: Fri, 17 Apr 2026 11:45:35 -0700 Subject: [PATCH 14/19] Format settings and IPC imports - No functional change - Keep staged code style consistent --- apps/web/src/components/settings/ConnectionsSettings.tsx | 4 +--- packages/contracts/src/ipc.ts | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 848c8370933..95e504207d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -2108,9 +2108,7 @@ export function ConnectionsSettings() { - setSavedBackendSshUsername(event.target.value) - } + onChange={(event) => setSavedBackendSshUsername(event.target.value)} placeholder="julius" disabled={isAddingSavedBackend} spellCheck={false} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index fc0413e468b..b3181504fb7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -52,7 +52,11 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import type { EnvironmentId } from "./baseSchemas.ts"; -import type { AuthBearerBootstrapResult, AuthSessionState, AuthWebSocketTokenResult } from "./auth.ts"; +import type { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, +} from "./auth.ts"; import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; From 544d6b3b040366cbd7b573908aa04bb91993e19f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:00:44 -0700 Subject: [PATCH 15/19] Extract desktop SSH bridge into dedicated class - Move SSH IPC handlers and password prompt state out of main.ts - Keep SSH environment launch and auth flow owned by sshEnvironment.ts --- apps/desktop/src/main.ts | 306 +-------------------------- apps/desktop/src/sshEnvironment.ts | 326 +++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+), 297 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 24e1b5b48a2..74baa16c54d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -20,13 +20,7 @@ import { } from "electron"; import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { - AuthBearerBootstrapResult, - AuthSessionState, - AuthWebSocketTokenResult, ClientSettings, - DesktopSshPasswordPromptRequest, - ExecutionEnvironmentDescriptor, - DesktopSshEnvironmentTarget, DesktopTheme, DesktopAppBranding, DesktopServerExposureMode, @@ -65,7 +59,7 @@ import { resolveDesktopCoreAdvertisedEndpoints, resolveDesktopServerExposure, } from "./serverExposure.ts"; -import { DesktopSshEnvironmentManager, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; +import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -111,14 +105,6 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; -const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; -const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; -const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; -const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; -const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; -const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; -const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; -const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; @@ -177,7 +163,6 @@ function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined { } const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; -const SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; const TITLEBAR_HEIGHT = 40; const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; @@ -222,12 +207,6 @@ type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; }; -interface PendingSshPasswordPrompt { - readonly resolve: (password: string | null) => void; - readonly reject: (error: Error) => void; - readonly timeout: ReturnType; -} - let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; @@ -251,7 +230,6 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion()); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; -const pendingSshPasswordPrompts = new Map(); let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); @@ -428,7 +406,7 @@ function relaunchDesktopApp(reason: string): void { `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) - .then(() => desktopSshEnvironmentManager.dispose().catch(() => undefined)) + .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { @@ -491,133 +469,6 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } -function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { - if (typeof rawTarget !== "object" || rawTarget === null) { - return null; - } - - const target = rawTarget as Partial; - if (typeof target.alias !== "string" || typeof target.hostname !== "string") { - return null; - } - if ( - target.username !== null && - target.username !== undefined && - typeof target.username !== "string" - ) { - return null; - } - if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { - return null; - } - - const alias = target.alias.trim(); - const hostname = target.hostname.trim(); - if (alias.length === 0 || hostname.length === 0) { - return null; - } - - return { - alias, - hostname, - username: target.username?.trim() || null, - port: target.port ?? null, - }; -} - -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname - .trim() - .toLowerCase() - .replace(/^\[(.*)\]$/, "$1"); - return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; -} - -function resolveLoopbackSshHttpUrl(rawHttpBaseUrl: unknown, pathname: string): URL { - if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { - throw new Error("Invalid SSH forwarded http base URL."); - } - - let baseUrl: URL; - try { - baseUrl = new URL(rawHttpBaseUrl); - } catch { - throw new Error("Invalid SSH forwarded http base URL."); - } - - if (!isLoopbackHostname(baseUrl.hostname)) { - throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); - } - - const url = new URL(baseUrl.toString()); - url.pathname = pathname; - url.search = ""; - url.hash = ""; - return url; -} - -async function readRemoteFetchErrorMessage( - response: Response, - fallbackMessage: string, -): Promise { - const text = await response.text(); - if (!text) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(text) as { readonly error?: string }; - if (typeof parsed.error === "string" && parsed.error.trim().length > 0) { - return parsed.error; - } - } catch { - // Fall back to the raw text below. - } - - return text; -} - -async function fetchLoopbackSshJson(input: { - readonly httpBaseUrl: unknown; - readonly pathname: string; - readonly method?: "GET" | "POST"; - readonly bearerToken?: unknown; - readonly body?: unknown; -}): Promise { - const requestUrl = resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname).toString(); - const bearerToken = - typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0 - ? input.bearerToken - : null; - - let response: Response; - try { - response = await fetch(requestUrl, { - method: input.method ?? "GET", - headers: { - ...(input.body !== undefined ? { "content-type": "application/json" } : {}), - ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}), - }, - ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), - }); - } catch (error) { - throw new Error( - `Failed to reach SSH forwarded endpoint ${requestUrl} (${error instanceof Error ? error.message : String(error)}).`, - { cause: error }, - ); - } - - if (!response.ok) { - const message = await readRemoteFetchErrorMessage( - response, - `SSH forwarded request failed (${response.status}).`, - ); - throw new Error(`[ssh_http:${response.status}] ${message}`); - } - - return (await response.json()) as T; -} - async function waitForBackendHttpReady( baseUrl: string, options?: Parameters[1], @@ -812,54 +663,8 @@ let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); -function rejectPendingSshPasswordPrompts(message: string): void { - for (const [requestId, pending] of pendingSshPasswordPrompts) { - clearTimeout(pending.timeout); - pendingSshPasswordPrompts.delete(requestId); - pending.reject(new Error(message)); - } -} - -async function requestSshPasswordFromRenderer(input: { - readonly destination: string; - readonly username: string | null; - readonly prompt: string; -}): Promise { - const window = mainWindow; - if (!window || window.isDestroyed()) { - throw new Error("T3 Code window is not available for SSH authentication."); - } - - const request: DesktopSshPasswordPromptRequest = { - requestId: Crypto.randomUUID(), - destination: input.destination, - username: input.username, - prompt: input.prompt, - }; - - return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingSshPasswordPrompts.delete(request.requestId); - reject(new Error(`SSH authentication timed out for ${input.destination}.`)); - }, SSH_PASSWORD_PROMPT_TIMEOUT_MS); - timeout.unref(); - - pendingSshPasswordPrompts.set(request.requestId, { - resolve, - reject, - timeout, - }); - - window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); - if (window.isMinimized()) { - window.restore(); - } - window.focus(); - }); -} - -const desktopSshEnvironmentManager = new DesktopSshEnvironmentManager({ - passwordProvider: requestSshPasswordFromRenderer, +const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ + getMainWindow: () => mainWindow, resolveCliPackageSpec: () => resolveRemoteT3CliPackageSpec({ appVersion: app.getVersion(), @@ -1886,100 +1691,7 @@ function registerIpcHandlers(): void { }, ); - ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); - ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => - desktopSshEnvironmentManager.discoverHosts(), - ); - - ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); - ipcMain.handle( - ENSURE_SSH_ENVIRONMENT_CHANNEL, - async (_event, rawTarget: unknown, rawOptions: unknown) => { - const target = getSafeDesktopSshTarget(rawTarget); - if (!target) { - throw new Error("Invalid desktop SSH target."); - } - - const issuePairingToken = - typeof rawOptions === "object" && - rawOptions !== null && - "issuePairingToken" in rawOptions && - (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; - - return await desktopSshEnvironmentManager.ensureEnvironment(target, { - issuePairingToken, - }); - }, - ); - - ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); - ipcMain.handle( - FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, - async (_event, rawHttpBaseUrl: unknown) => - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/.well-known/t3/environment", - }), - ); - - ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); - ipcMain.handle( - BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, - async (_event, rawHttpBaseUrl: unknown, rawCredential: unknown) => - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/bootstrap/bearer", - method: "POST", - body: { - credential: rawCredential, - }, - }), - ); - - ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); - ipcMain.handle( - FETCH_SSH_SESSION_STATE_CHANNEL, - async (_event, rawHttpBaseUrl: unknown, rawBearerToken: unknown) => - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/session", - bearerToken: rawBearerToken, - }), - ); - - ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); - ipcMain.handle( - ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, - async (_event, rawHttpBaseUrl: unknown, rawBearerToken: unknown) => - fetchLoopbackSshJson({ - httpBaseUrl: rawHttpBaseUrl, - pathname: "/api/auth/ws-token", - method: "POST", - bearerToken: rawBearerToken, - }), - ); - - ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); - ipcMain.handle( - RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, - async (_event, rawRequestId: unknown, rawPassword: unknown) => { - if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { - throw new Error("Invalid SSH password prompt id."); - } - if (rawPassword !== null && typeof rawPassword !== "string") { - throw new Error("Invalid SSH password prompt response."); - } - - const pending = pendingSshPasswordPrompts.get(rawRequestId); - if (!pending) { - throw new Error("SSH password prompt is no longer pending."); - } - - clearTimeout(pending.timeout); - pendingSshPasswordPrompts.delete(rawRequestId); - pending.resolve(rawPassword); - }, - ); + desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); @@ -2356,7 +2068,7 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { - rejectPendingSshPasswordPrompts( + desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( "SSH authentication was cancelled because the app window closed.", ); if (mainWindow === window) { @@ -2450,7 +2162,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); - void desktopSshEnvironmentManager.dispose().catch(() => undefined); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); @@ -2500,7 +2212,7 @@ if (process.platform !== "win32") { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); - void desktopSshEnvironmentManager.dispose().catch(() => undefined); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); @@ -2511,7 +2223,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGTERM received"); clearUpdatePollTimer(); stopBackend(); - void desktopSshEnvironmentManager.dispose().catch(() => undefined); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index bdca2f7cda2..7c423a7e79e 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -6,10 +6,15 @@ import * as Path from "node:path"; import * as Net from "node:net"; import type { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, DesktopDiscoveredSshHost, DesktopSshEnvironmentBootstrap, DesktopSshEnvironmentTarget, + DesktopSshPasswordPromptRequest, DesktopUpdateChannel, + ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; import { waitForHttpReady } from "./backendReadiness"; @@ -21,6 +26,16 @@ const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const SSH_READY_TIMEOUT_MS = 20_000; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; + interface SshTunnelEntry { readonly key: string; readonly target: DesktopSshEnvironmentTarget; @@ -1148,6 +1163,317 @@ export class DesktopSshEnvironmentManager { } } +function getSafeDesktopSshTarget(rawTarget: unknown): DesktopSshEnvironmentTarget | null { + if (typeof rawTarget !== "object" || rawTarget === null) { + return null; + } + + const target = rawTarget as Partial; + if (typeof target.alias !== "string" || typeof target.hostname !== "string") { + return null; + } + if ( + target.username !== null && + target.username !== undefined && + typeof target.username !== "string" + ) { + return null; + } + if (target.port !== null && target.port !== undefined && !Number.isInteger(target.port)) { + return null; + } + + const alias = target.alias.trim(); + const hostname = target.hostname.trim(); + if (alias.length === 0 || hostname.length === 0) { + return null; + } + + return { + alias, + hostname, + username: target.username?.trim() || null, + port: target.port ?? null, + }; +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost"; +} + +function resolveLoopbackSshHttpUrl(rawHttpBaseUrl: unknown, pathname: string): URL { + if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { + throw new Error("Invalid SSH forwarded http base URL."); + } + + let baseUrl: URL; + try { + baseUrl = new URL(rawHttpBaseUrl); + } catch { + throw new Error("Invalid SSH forwarded http base URL."); + } + + if (!isLoopbackHostname(baseUrl.hostname)) { + throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); + } + + const url = new URL(baseUrl.toString()); + url.pathname = pathname; + url.search = ""; + url.hash = ""; + return url; +} + +async function readRemoteFetchErrorMessage( + response: Response, + fallbackMessage: string, +): Promise { + const text = await response.text(); + if (!text) { + return fallbackMessage; + } + + try { + const parsed = JSON.parse(text) as { readonly error?: string }; + if (typeof parsed.error === "string" && parsed.error.trim().length > 0) { + return parsed.error; + } + } catch { + // Fall back to the raw text below. + } + + return text; +} + +async function fetchLoopbackSshJson(input: { + readonly httpBaseUrl: unknown; + readonly pathname: string; + readonly method?: "GET" | "POST"; + readonly bearerToken?: unknown; + readonly body?: unknown; +}): Promise { + const requestUrl = resolveLoopbackSshHttpUrl(input.httpBaseUrl, input.pathname).toString(); + const bearerToken = + typeof input.bearerToken === "string" && input.bearerToken.trim().length > 0 + ? input.bearerToken + : null; + + let response: Response; + try { + response = await fetch(requestUrl, { + method: input.method ?? "GET", + headers: { + ...(input.body !== undefined ? { "content-type": "application/json" } : {}), + ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}), + }, + ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}), + }); + } catch (error) { + throw new Error( + `Failed to reach SSH forwarded endpoint ${requestUrl} (${error instanceof Error ? error.message : String(error)}).`, + { cause: error }, + ); + } + + if (!response.ok) { + const message = await readRemoteFetchErrorMessage( + response, + `SSH forwarded request failed (${response.status}).`, + ); + throw new Error(`[ssh_http:${response.status}] ${message}`); + } + + return (await response.json()) as T; +} + +/** Minimal subset of Electron's BrowserWindow used by the SSH bridge. */ +export interface DesktopSshBridgeWindow { + isDestroyed(): boolean; + isMinimized(): boolean; + restore(): void; + focus(): void; + readonly webContents: { + send(channel: string, ...args: readonly unknown[]): void; + }; +} + +/** Minimal subset of Electron's ipcMain used by the SSH bridge. */ +export interface DesktopSshBridgeIpcMain { + removeHandler(channel: string): void; + handle( + channel: string, + listener: (event: unknown, ...args: readonly unknown[]) => unknown | Promise, + ): void; +} + +export interface DesktopSshEnvironmentBridgeOptions { + readonly getMainWindow: () => DesktopSshBridgeWindow | null; + readonly resolveCliPackageSpec: () => string; + readonly passwordPromptTimeoutMs?: number; +} + +interface PendingSshPasswordPrompt { + readonly resolve: (password: string | null) => void; + readonly reject: (error: Error) => void; + readonly timeout: ReturnType; +} + +/** + * Wires the SSH environment manager to Electron IPC, owning the renderer-facing + * password prompt state so `main.ts` only needs to register, cancel, and dispose. + */ +export class DesktopSshEnvironmentBridge { + private readonly options: DesktopSshEnvironmentBridgeOptions; + private readonly manager: DesktopSshEnvironmentManager; + private readonly pendingPrompts = new Map(); + private readonly passwordPromptTimeoutMs: number; + + constructor(options: DesktopSshEnvironmentBridgeOptions) { + this.options = options; + this.passwordPromptTimeoutMs = + options.passwordPromptTimeoutMs ?? DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS; + this.manager = new DesktopSshEnvironmentManager({ + passwordProvider: (request) => this.requestPasswordFromRenderer(request), + resolveCliPackageSpec: options.resolveCliPackageSpec, + }); + } + + registerIpcHandlers(ipcMain: DesktopSshBridgeIpcMain): void { + ipcMain.removeHandler(DISCOVER_SSH_HOSTS_CHANNEL); + ipcMain.handle(DISCOVER_SSH_HOSTS_CHANNEL, async () => this.manager.discoverHosts()); + + ipcMain.removeHandler(ENSURE_SSH_ENVIRONMENT_CHANNEL); + ipcMain.handle(ENSURE_SSH_ENVIRONMENT_CHANNEL, async (_event, rawTarget, rawOptions) => { + const target = getSafeDesktopSshTarget(rawTarget); + if (!target) { + throw new Error("Invalid desktop SSH target."); + } + + const issuePairingToken = + typeof rawOptions === "object" && + rawOptions !== null && + "issuePairingToken" in rawOptions && + (rawOptions as { issuePairingToken?: unknown }).issuePairingToken === true; + + return await this.manager.ensureEnvironment(target, { issuePairingToken }); + }); + + ipcMain.removeHandler(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL); + ipcMain.handle(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, async (_event, rawHttpBaseUrl) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/.well-known/t3/environment", + }), + ); + + ipcMain.removeHandler(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL); + ipcMain.handle( + BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, + async (_event, rawHttpBaseUrl, rawCredential) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/bootstrap/bearer", + method: "POST", + body: { credential: rawCredential }, + }), + ); + + ipcMain.removeHandler(FETCH_SSH_SESSION_STATE_CHANNEL); + ipcMain.handle( + FETCH_SSH_SESSION_STATE_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/session", + bearerToken: rawBearerToken, + }), + ); + + ipcMain.removeHandler(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL); + ipcMain.handle( + ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, + async (_event, rawHttpBaseUrl, rawBearerToken) => + fetchLoopbackSshJson({ + httpBaseUrl: rawHttpBaseUrl, + pathname: "/api/auth/ws-token", + method: "POST", + bearerToken: rawBearerToken, + }), + ); + + ipcMain.removeHandler(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL); + ipcMain.handle( + RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, + async (_event, rawRequestId, rawPassword) => { + if (typeof rawRequestId !== "string" || rawRequestId.trim().length === 0) { + throw new Error("Invalid SSH password prompt id."); + } + if (rawPassword !== null && typeof rawPassword !== "string") { + throw new Error("Invalid SSH password prompt response."); + } + + const pending = this.pendingPrompts.get(rawRequestId); + if (!pending) { + throw new Error("SSH password prompt is no longer pending."); + } + + clearTimeout(pending.timeout); + this.pendingPrompts.delete(rawRequestId); + pending.resolve(rawPassword); + }, + ); + } + + cancelPendingPasswordPrompts(reason: string): void { + for (const [requestId, pending] of this.pendingPrompts) { + clearTimeout(pending.timeout); + this.pendingPrompts.delete(requestId); + pending.reject(new Error(reason)); + } + } + + async dispose(): Promise { + this.cancelPendingPasswordPrompts("SSH environment bridge disposed."); + await this.manager.dispose(); + } + + private async requestPasswordFromRenderer( + input: DesktopSshPasswordRequest, + ): Promise { + const window = this.options.getMainWindow(); + if (!window || window.isDestroyed()) { + throw new Error("T3 Code window is not available for SSH authentication."); + } + + const request: DesktopSshPasswordPromptRequest = { + requestId: Crypto.randomUUID(), + destination: input.destination, + username: input.username, + prompt: input.prompt, + }; + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingPrompts.delete(request.requestId); + reject(new Error(`SSH authentication timed out for ${input.destination}.`)); + }, this.passwordPromptTimeoutMs); + timeout.unref(); + + this.pendingPrompts.set(request.requestId, { resolve, reject, timeout }); + + window.webContents.send(SSH_PASSWORD_PROMPT_CHANNEL, request); + if (window.isMinimized()) { + window.restore(); + } + window.focus(); + }); + } +} + export const __test = { baseSshArgs, buildRemoteLaunchScript, From 63fc87ab63c96adbeb24a195773a37b4e0a4e573 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:50:36 -0700 Subject: [PATCH 16/19] Extract SSH launch scripts - Externalize askpass, remote launch, and runner helpers into script assets - Copy SSH scripts into `dist-electron` for packaging - Co-authored-by: codex --- apps/desktop/src/sshEnvironment.ts | 237 ++++-------------- apps/desktop/src/sshScripts/askpass-posix.sh | 33 +++ .../src/sshScripts/askpass-windows.cmd | 2 + .../src/sshScripts/askpass-windows.ps1 | 48 ++++ apps/desktop/src/sshScripts/remote-launch.sh | 34 +++ apps/desktop/src/sshScripts/remote-pairing.sh | 10 + .../src/sshScripts/remote-pick-port.cjs | 31 +++ apps/desktop/src/sshScripts/remote-runner.sh | 13 + apps/desktop/tsdown.config.ts | 1 + 9 files changed, 225 insertions(+), 184 deletions(-) create mode 100644 apps/desktop/src/sshScripts/askpass-posix.sh create mode 100644 apps/desktop/src/sshScripts/askpass-windows.cmd create mode 100644 apps/desktop/src/sshScripts/askpass-windows.ps1 create mode 100644 apps/desktop/src/sshScripts/remote-launch.sh create mode 100644 apps/desktop/src/sshScripts/remote-pairing.sh create mode 100644 apps/desktop/src/sshScripts/remote-pick-port.cjs create mode 100644 apps/desktop/src/sshScripts/remote-runner.sh diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 7c423a7e79e..12a790968c4 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -315,97 +315,48 @@ function getDefaultSshAskpassDirectory(): string { return Path.join(OS.tmpdir(), SSH_ASKPASS_DIR_NAME); } +const SSH_SCRIPTS_DIR = Path.join(__dirname, "sshScripts"); +const sshScriptCache = new Map(); + +function readSshScriptTemplate(fileName: string): string { + const cached = sshScriptCache.get(fileName); + if (cached !== undefined) { + return cached; + } + const contents = FS.readFileSync(Path.join(SSH_SCRIPTS_DIR, fileName), "utf8"); + sshScriptCache.set(fileName, contents); + return contents; +} + +function stripTrailingNewlines(value: string): string { + return value.replace(/\n+$/u, ""); +} + +function applyScriptPlaceholders( + template: string, + replacements: Readonly>, +): string { + let result = template; + for (const [token, value] of Object.entries(replacements)) { + result = result.replaceAll(`@@${token}@@`, value); + } + return result; +} + +function toCrlf(value: string): string { + return value.replace(/\r?\n/gu, "\r\n"); +} + function buildPosixSshAskpassScript(): string { - return [ - "#!/bin/sh", - "set -eu", - 'PROMPT="${1:-SSH authentication}"', - 'if [ "${T3_SSH_AUTH_SECRET+x}" = "x" ]; then', - ' printf "%s\\n" "$T3_SSH_AUTH_SECRET"', - " exit 0", - "fi", - "if command -v osascript >/dev/null 2>&1; then", - " T3_SSH_ASKPASS_PROMPT=\"$PROMPT\" /usr/bin/osascript <<'APPLESCRIPT'", - 'set promptText to system attribute "T3_SSH_ASKPASS_PROMPT"', - "try", - ' set dialogResult to display dialog promptText default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel"', - " text returned of dialogResult", - "on error number -128", - " error number -128", - "end try", - "APPLESCRIPT", - " exit $?", - "fi", - "if command -v zenity >/dev/null 2>&1; then", - ' zenity --password --title="SSH authentication" --text="$PROMPT"', - " exit $?", - "fi", - "if command -v kdialog >/dev/null 2>&1; then", - ' kdialog --title "SSH authentication" --password "$PROMPT"', - " exit $?", - "fi", - "if command -v ssh-askpass >/dev/null 2>&1; then", - ' ssh-askpass "$PROMPT"', - " exit $?", - "fi", - "printf 'Unable to open an SSH password prompt on this desktop.\\n' >&2", - "exit 1", - "", - ].join("\n"); + return readSshScriptTemplate("askpass-posix.sh"); } function buildWindowsSshAskpassScript(): string { - return [ - "if ($env:T3_SSH_AUTH_SECRET -ne $null) {", - " [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET)", - " exit 0", - "}", - "Add-Type -AssemblyName System.Windows.Forms", - "[System.Windows.Forms.Application]::EnableVisualStyles()", - '$prompt = if ($args.Length -gt 0 -and $args[0]) { $args[0] } else { "SSH authentication" }', - "$form = New-Object System.Windows.Forms.Form", - '$form.Text = "SSH authentication"', - "$form.Width = 420", - "$form.Height = 185", - '$form.StartPosition = "CenterScreen"', - '$form.FormBorderStyle = "FixedDialog"', - "$form.MaximizeBox = $false", - "$form.MinimizeBox = $false", - "$form.TopMost = $true", - "$label = New-Object System.Windows.Forms.Label", - "$label.Left = 16", - "$label.Top = 16", - "$label.Width = 372", - "$label.Height = 34", - "$label.Text = $prompt", - "$textbox = New-Object System.Windows.Forms.TextBox", - "$textbox.Left = 16", - "$textbox.Top = 60", - "$textbox.Width = 372", - "$textbox.UseSystemPasswordChar = $true", - "$okButton = New-Object System.Windows.Forms.Button", - '$okButton.Text = "OK"', - "$okButton.Left = 232", - "$okButton.Top = 100", - "$okButton.Width = 75", - "$cancelButton = New-Object System.Windows.Forms.Button", - '$cancelButton.Text = "Cancel"', - "$cancelButton.Left = 313", - "$cancelButton.Top = 100", - "$cancelButton.Width = 75", - "$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK", - "$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel", - "$form.AcceptButton = $okButton", - "$form.CancelButton = $cancelButton", - "$form.Controls.Add($label)", - "$form.Controls.Add($textbox)", - "$form.Controls.Add($okButton)", - "$form.Controls.Add($cancelButton)", - "$result = $form.ShowDialog()", - "if ($result -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 }", - "[Console]::Out.WriteLine($textbox.Text)", - "", - ].join("\r\n"); + return toCrlf(readSshScriptTemplate("askpass-windows.ps1")); +} + +function buildWindowsSshAskpassLauncherScript(): string { + return toCrlf(readSshScriptTemplate("askpass-windows.cmd")); } function buildSshAskpassHelperDescriptor(input?: { @@ -423,11 +374,7 @@ function buildSshAskpassHelperDescriptor(input?: { files: [ { path: pathModule.join(directory, "ssh-askpass.cmd"), - contents: [ - "@echo off", - 'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*', - "", - ].join("\r\n"), + contents: buildWindowsSshAskpassLauncherScript(), }, { path: powershellPath, @@ -640,73 +587,14 @@ async function resolveDesktopSshTarget(alias: string): Promise"$RUNNER_FILE" <<'SH' -${runnerScript} -SH -chmod 700 "$RUNNER_FILE" -pick_port() { - node - "$PORT_FILE" <<'NODE' -const fs = require("node:fs"); -const net = require("node:net"); -const filePath = process.argv[2]; -const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : ""; -const preferred = Number.parseInt(raw, 10); -const start = Number.isInteger(preferred) ? preferred : ${DEFAULT_REMOTE_PORT}; -const end = start + ${REMOTE_PORT_SCAN_WINDOW}; - -function tryPort(port) { - return new Promise((resolve) => { - const server = net.createServer(); - server.unref(); - server.once("error", () => resolve(false)); - server.listen(port, "127.0.0.1", () => { - server.close((error) => resolve(error ? false : port)); - }); + return applyScriptPlaceholders(readSshScriptTemplate("remote-launch.sh"), { + T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), + T3_PICK_PORT_SCRIPT: stripTrailingNewlines(readSshScriptTemplate("remote-pick-port.cjs")), + T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT), + T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), }); } -(async () => { - for (let port = start; port < end; port += 1) { - const available = await tryPort(port); - if (available) { - process.stdout.write(String(port)); - return; - } - } - process.exit(1); -})().catch(() => process.exit(1)); -NODE -} -REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" -REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" -if [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then - : -else - REMOTE_PORT="$(pick_port)" || true - if [ -z "$REMOTE_PORT" ]; then - printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\\n' >&2 - exit 1 - fi - nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & - REMOTE_PID="$!" - printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" - printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" -fi -printf '{"remotePort":%s}\\n' "$REMOTE_PORT" -`.trimStart(); -} - function getLastNonEmptyOutputLine(stdout: string): string | null { return ( stdout @@ -736,40 +624,21 @@ export function resolveRemoteT3CliPackageSpec(input: { function buildRemoteT3RunnerScript(input?: { readonly packageSpec?: string }): string { const packageSpec = input?.packageSpec?.trim() || "t3@latest"; - return [ - "#!/bin/sh", - "set -eu", - "if command -v t3 >/dev/null 2>&1; then", - ' exec t3 "$@"', - "fi", - "if command -v npx >/dev/null 2>&1; then", - ` exec npx --yes ${packageSpec} "$@"`, - "fi", - "if command -v npm >/dev/null 2>&1; then", - ` exec npm exec --yes ${packageSpec} -- "$@"`, - "fi", - `printf 'Remote host is missing the t3 CLI and could not install ${packageSpec} because npx and npm are unavailable on PATH.\\n' >&2`, - "exit 1", - ].join("\n"); + return stripTrailingNewlines( + applyScriptPlaceholders(readSshScriptTemplate("remote-runner.sh"), { + T3_PACKAGE_SPEC: packageSpec, + }), + ); } function buildRemotePairingScript( target: DesktopSshEnvironmentTarget, input?: { readonly packageSpec?: string }, ): string { - const runnerScript = buildRemoteT3RunnerScript(input); - return ` -set -eu -STATE_DIR="$HOME/.t3/ssh-launch/${remoteStateKey(target)}" -SERVER_HOME="$STATE_DIR/server-home" -RUNNER_FILE="$STATE_DIR/run-t3.sh" -mkdir -p "$STATE_DIR" "$SERVER_HOME" -cat >"$RUNNER_FILE" <<'SH' -${runnerScript} -SH -chmod 700 "$RUNNER_FILE" -"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json -`.trimStart(); + return applyScriptPlaceholders(readSshScriptTemplate("remote-pairing.sh"), { + T3_STATE_KEY: remoteStateKey(target), + T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), + }); } async function launchOrReuseRemoteServer( diff --git a/apps/desktop/src/sshScripts/askpass-posix.sh b/apps/desktop/src/sshScripts/askpass-posix.sh new file mode 100644 index 00000000000..d06bde82221 --- /dev/null +++ b/apps/desktop/src/sshScripts/askpass-posix.sh @@ -0,0 +1,33 @@ +#!/bin/sh +set -eu +PROMPT="${1:-SSH authentication}" +if [ "${T3_SSH_AUTH_SECRET+x}" = "x" ]; then + printf "%s\n" "$T3_SSH_AUTH_SECRET" + exit 0 +fi +if command -v osascript >/dev/null 2>&1; then + T3_SSH_ASKPASS_PROMPT="$PROMPT" /usr/bin/osascript <<'APPLESCRIPT' +set promptText to system attribute "T3_SSH_ASKPASS_PROMPT" +try + set dialogResult to display dialog promptText default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" + text returned of dialogResult +on error number -128 + error number -128 +end try +APPLESCRIPT + exit $? +fi +if command -v zenity >/dev/null 2>&1; then + zenity --password --title="SSH authentication" --text="$PROMPT" + exit $? +fi +if command -v kdialog >/dev/null 2>&1; then + kdialog --title "SSH authentication" --password "$PROMPT" + exit $? +fi +if command -v ssh-askpass >/dev/null 2>&1; then + ssh-askpass "$PROMPT" + exit $? +fi +printf 'Unable to open an SSH password prompt on this desktop.\n' >&2 +exit 1 diff --git a/apps/desktop/src/sshScripts/askpass-windows.cmd b/apps/desktop/src/sshScripts/askpass-windows.cmd new file mode 100644 index 00000000000..6421710215a --- /dev/null +++ b/apps/desktop/src/sshScripts/askpass-windows.cmd @@ -0,0 +1,2 @@ +@echo off +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %* diff --git a/apps/desktop/src/sshScripts/askpass-windows.ps1 b/apps/desktop/src/sshScripts/askpass-windows.ps1 new file mode 100644 index 00000000000..277821d387f --- /dev/null +++ b/apps/desktop/src/sshScripts/askpass-windows.ps1 @@ -0,0 +1,48 @@ +if ($env:T3_SSH_AUTH_SECRET -ne $null) { + [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET) + exit 0 +} +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.Application]::EnableVisualStyles() +$prompt = if ($args.Length -gt 0 -and $args[0]) { $args[0] } else { "SSH authentication" } +$form = New-Object System.Windows.Forms.Form +$form.Text = "SSH authentication" +$form.Width = 420 +$form.Height = 185 +$form.StartPosition = "CenterScreen" +$form.FormBorderStyle = "FixedDialog" +$form.MaximizeBox = $false +$form.MinimizeBox = $false +$form.TopMost = $true +$label = New-Object System.Windows.Forms.Label +$label.Left = 16 +$label.Top = 16 +$label.Width = 372 +$label.Height = 34 +$label.Text = $prompt +$textbox = New-Object System.Windows.Forms.TextBox +$textbox.Left = 16 +$textbox.Top = 60 +$textbox.Width = 372 +$textbox.UseSystemPasswordChar = $true +$okButton = New-Object System.Windows.Forms.Button +$okButton.Text = "OK" +$okButton.Left = 232 +$okButton.Top = 100 +$okButton.Width = 75 +$cancelButton = New-Object System.Windows.Forms.Button +$cancelButton.Text = "Cancel" +$cancelButton.Left = 313 +$cancelButton.Top = 100 +$cancelButton.Width = 75 +$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK +$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel +$form.AcceptButton = $okButton +$form.CancelButton = $cancelButton +$form.Controls.Add($label) +$form.Controls.Add($textbox) +$form.Controls.Add($okButton) +$form.Controls.Add($cancelButton) +$result = $form.ShowDialog() +if ($result -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 } +[Console]::Out.WriteLine($textbox.Text) diff --git a/apps/desktop/src/sshScripts/remote-launch.sh b/apps/desktop/src/sshScripts/remote-launch.sh new file mode 100644 index 00000000000..ebe50c8bfaf --- /dev/null +++ b/apps/desktop/src/sshScripts/remote-launch.sh @@ -0,0 +1,34 @@ +set -eu +STATE_KEY="$1" +STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" +SERVER_HOME="$STATE_DIR/server-home" +PORT_FILE="$STATE_DIR/port" +PID_FILE="$STATE_DIR/pid" +LOG_FILE="$STATE_DIR/server.log" +RUNNER_FILE="$STATE_DIR/run-t3.sh" +mkdir -p "$STATE_DIR" "$SERVER_HOME" +cat >"$RUNNER_FILE" <<'SH' +@@T3_RUNNER_SCRIPT@@ +SH +chmod 700 "$RUNNER_FILE" +pick_port() { + node - "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@" <<'NODE' +@@T3_PICK_PORT_SCRIPT@@ +NODE +} +REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" +REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" +if [ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then + : +else + REMOTE_PORT="$(pick_port)" || true + if [ -z "$REMOTE_PORT" ]; then + printf 'Failed to find an available port on the remote host. Ensure node is available on PATH.\n' >&2 + exit 1 + fi + nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & + REMOTE_PID="$!" + printf '%s\n' "$REMOTE_PID" >"$PID_FILE" + printf '%s\n' "$REMOTE_PORT" >"$PORT_FILE" +fi +printf '{"remotePort":%s}\n' "$REMOTE_PORT" diff --git a/apps/desktop/src/sshScripts/remote-pairing.sh b/apps/desktop/src/sshScripts/remote-pairing.sh new file mode 100644 index 00000000000..4e9d850e103 --- /dev/null +++ b/apps/desktop/src/sshScripts/remote-pairing.sh @@ -0,0 +1,10 @@ +set -eu +STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" +SERVER_HOME="$STATE_DIR/server-home" +RUNNER_FILE="$STATE_DIR/run-t3.sh" +mkdir -p "$STATE_DIR" "$SERVER_HOME" +cat >"$RUNNER_FILE" <<'SH' +@@T3_RUNNER_SCRIPT@@ +SH +chmod 700 "$RUNNER_FILE" +"$RUNNER_FILE" auth pairing create --base-dir "$SERVER_HOME" --json diff --git a/apps/desktop/src/sshScripts/remote-pick-port.cjs b/apps/desktop/src/sshScripts/remote-pick-port.cjs new file mode 100644 index 00000000000..8021fedb43d --- /dev/null +++ b/apps/desktop/src/sshScripts/remote-pick-port.cjs @@ -0,0 +1,31 @@ +const fs = require("node:fs"); +const net = require("node:net"); +const filePath = process.argv[2]; +const defaultPort = Number.parseInt(process.argv[3] ?? "", 10); +const scanWindow = Number.parseInt(process.argv[4] ?? "", 10); +const raw = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8").trim() : ""; +const preferred = Number.parseInt(raw, 10); +const start = Number.isInteger(preferred) ? preferred : defaultPort; +const end = start + scanWindow; + +function tryPort(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close((error) => resolve(error ? false : port)); + }); + }); +} + +(async () => { + for (let port = start; port < end; port += 1) { + const available = await tryPort(port); + if (available) { + process.stdout.write(String(port)); + return; + } + } + process.exit(1); +})().catch(() => process.exit(1)); diff --git a/apps/desktop/src/sshScripts/remote-runner.sh b/apps/desktop/src/sshScripts/remote-runner.sh new file mode 100644 index 00000000000..c9a1b2164e2 --- /dev/null +++ b/apps/desktop/src/sshScripts/remote-runner.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +if command -v t3 >/dev/null 2>&1; then + exec t3 "$@" +fi +if command -v npx >/dev/null 2>&1; then + exec npx --yes @@T3_PACKAGE_SPEC@@ "$@" +fi +if command -v npm >/dev/null 2>&1; then + exec npm exec --yes @@T3_PACKAGE_SPEC@@ -- "$@" +fi +printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPEC@@ because npx and npm are unavailable on PATH.\n' >&2 +exit 1 diff --git a/apps/desktop/tsdown.config.ts b/apps/desktop/tsdown.config.ts index 74067b127d0..2efcad6feec 100644 --- a/apps/desktop/tsdown.config.ts +++ b/apps/desktop/tsdown.config.ts @@ -13,6 +13,7 @@ export default defineConfig([ entry: ["src/main.ts"], clean: true, noExternal: (id) => id.startsWith("@t3tools/") || id.startsWith("effect-acp"), + copy: [{ from: "src/sshScripts/*", to: "dist-electron/sshScripts" }], }, { ...shared, From 8a2c28d88ec66a863455c1f880750493834ef040 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 14:55:28 -0700 Subject: [PATCH 17/19] Simplify ssh askpass to require cached secret - Remove native password prompts from posix and Windows scripts - Fail loudly when T3_SSH_AUTH_SECRET is missing --- apps/desktop/src/sshScripts/askpass-posix.sh | 31 ++--------- .../src/sshScripts/askpass-windows.ps1 | 52 +++---------------- 2 files changed, 11 insertions(+), 72 deletions(-) diff --git a/apps/desktop/src/sshScripts/askpass-posix.sh b/apps/desktop/src/sshScripts/askpass-posix.sh index d06bde82221..56e3bc5b253 100644 --- a/apps/desktop/src/sshScripts/askpass-posix.sh +++ b/apps/desktop/src/sshScripts/askpass-posix.sh @@ -1,33 +1,10 @@ #!/bin/sh -set -eu -PROMPT="${1:-SSH authentication}" +# Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password +# from the renderer's in-app prompt. We never expose a native dialog here — if +# T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly. if [ "${T3_SSH_AUTH_SECRET+x}" = "x" ]; then printf "%s\n" "$T3_SSH_AUTH_SECRET" exit 0 fi -if command -v osascript >/dev/null 2>&1; then - T3_SSH_ASKPASS_PROMPT="$PROMPT" /usr/bin/osascript <<'APPLESCRIPT' -set promptText to system attribute "T3_SSH_ASKPASS_PROMPT" -try - set dialogResult to display dialog promptText default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" cancel button "Cancel" - text returned of dialogResult -on error number -128 - error number -128 -end try -APPLESCRIPT - exit $? -fi -if command -v zenity >/dev/null 2>&1; then - zenity --password --title="SSH authentication" --text="$PROMPT" - exit $? -fi -if command -v kdialog >/dev/null 2>&1; then - kdialog --title "SSH authentication" --password "$PROMPT" - exit $? -fi -if command -v ssh-askpass >/dev/null 2>&1; then - ssh-askpass "$PROMPT" - exit $? -fi -printf 'Unable to open an SSH password prompt on this desktop.\n' >&2 +printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\n' >&2 exit 1 diff --git a/apps/desktop/src/sshScripts/askpass-windows.ps1 b/apps/desktop/src/sshScripts/askpass-windows.ps1 index 277821d387f..c24cfc21998 100644 --- a/apps/desktop/src/sshScripts/askpass-windows.ps1 +++ b/apps/desktop/src/sshScripts/askpass-windows.ps1 @@ -1,48 +1,10 @@ -if ($env:T3_SSH_AUTH_SECRET -ne $null) { +# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs +# ssh with a cached password from the renderer's in-app prompt. We never expose +# a native dialog here — if T3_SSH_AUTH_SECRET is missing, that's a caller bug +# and we fail loudly. +if ($null -ne $env:T3_SSH_AUTH_SECRET) { [Console]::Out.WriteLine($env:T3_SSH_AUTH_SECRET) exit 0 } -Add-Type -AssemblyName System.Windows.Forms -[System.Windows.Forms.Application]::EnableVisualStyles() -$prompt = if ($args.Length -gt 0 -and $args[0]) { $args[0] } else { "SSH authentication" } -$form = New-Object System.Windows.Forms.Form -$form.Text = "SSH authentication" -$form.Width = 420 -$form.Height = 185 -$form.StartPosition = "CenterScreen" -$form.FormBorderStyle = "FixedDialog" -$form.MaximizeBox = $false -$form.MinimizeBox = $false -$form.TopMost = $true -$label = New-Object System.Windows.Forms.Label -$label.Left = 16 -$label.Top = 16 -$label.Width = 372 -$label.Height = 34 -$label.Text = $prompt -$textbox = New-Object System.Windows.Forms.TextBox -$textbox.Left = 16 -$textbox.Top = 60 -$textbox.Width = 372 -$textbox.UseSystemPasswordChar = $true -$okButton = New-Object System.Windows.Forms.Button -$okButton.Text = "OK" -$okButton.Left = 232 -$okButton.Top = 100 -$okButton.Width = 75 -$cancelButton = New-Object System.Windows.Forms.Button -$cancelButton.Text = "Cancel" -$cancelButton.Left = 313 -$cancelButton.Top = 100 -$cancelButton.Width = 75 -$okButton.DialogResult = [System.Windows.Forms.DialogResult]::OK -$cancelButton.DialogResult = [System.Windows.Forms.DialogResult]::Cancel -$form.AcceptButton = $okButton -$form.CancelButton = $cancelButton -$form.Controls.Add($label) -$form.Controls.Add($textbox) -$form.Controls.Add($okButton) -$form.Controls.Add($cancelButton) -$result = $form.ShowDialog() -if ($result -ne [System.Windows.Forms.DialogResult]::OK) { exit 1 } -[Console]::Out.WriteLine($textbox.Text) +[Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.") +exit 1 From fd01a20f0d1c6a486978c070f15e1d5b4a8b07f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 15:26:05 -0700 Subject: [PATCH 18/19] Fix PR CI failures - use type-only imports required by verbatim module syntax - fix SSH desktop build/typecheck regressions and auth test isolation - tighten browser test selectors for the add-environment dialog Co-authored-by: codex --- apps/desktop/src/sshEnvironment.test.ts | 2 +- apps/desktop/src/sshEnvironment.ts | 42 ++++++++++++------- apps/server/src/server.test.ts | 8 ++-- .../settings/SettingsPanels.browser.tsx | 8 +++- packages/contracts/src/ipc.ts | 3 +- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts index 2b4f0cadc88..fe7274b8b90 100644 --- a/apps/desktop/src/sshEnvironment.test.ts +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -5,7 +5,7 @@ import * as Path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { __test, discoverDesktopSshHosts } from "./sshEnvironment"; +import { __test, discoverDesktopSshHosts } from "./sshEnvironment.ts"; const tempDirectories: string[] = []; diff --git a/apps/desktop/src/sshEnvironment.ts b/apps/desktop/src/sshEnvironment.ts index 12a790968c4..16a75dc8cb6 100644 --- a/apps/desktop/src/sshEnvironment.ts +++ b/apps/desktop/src/sshEnvironment.ts @@ -17,7 +17,7 @@ import type { ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; -import { waitForHttpReady } from "./backendReadiness"; +import { waitForHttpReady } from "./backendReadiness.ts"; const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; @@ -101,13 +101,13 @@ function expandHomePath(input: string, homeDir: string = OS.homedir()): string { function resolveSshConfigIncludePattern( includePattern: string, - directory: string, + _directory: string, homeDir: string = OS.homedir(), ): string { const expandedPattern = expandHomePath(includePattern, homeDir); return Path.isAbsolute(expandedPattern) ? expandedPattern - : Path.resolve(directory, expandedPattern); + : Path.resolve(Path.join(homeDir, ".ssh"), expandedPattern); } function hasSshPattern(value: string): boolean { @@ -147,6 +147,7 @@ function expandGlob(pattern: string): ReadonlyArray { function collectSshConfigAliasesFromFile( filePath: string, visited = new Set(), + homeDir: string = OS.homedir(), ): ReadonlyArray { const resolvedPath = Path.resolve(filePath); if (visited.has(resolvedPath) || !FS.existsSync(resolvedPath)) { @@ -168,9 +169,9 @@ function collectSshConfigAliasesFromFile( const normalizedDirective = directive.toLowerCase(); if (normalizedDirective === "include") { for (const includePattern of rawArgs) { - const resolvedPattern = resolveSshConfigIncludePattern(includePattern, directory); + const resolvedPattern = resolveSshConfigIncludePattern(includePattern, directory, homeDir); for (const includedPath of expandGlob(resolvedPattern)) { - for (const alias of collectSshConfigAliasesFromFile(includedPath, visited)) { + for (const alias of collectSshConfigAliasesFromFile(includedPath, visited, homeDir)) { aliases.add(alias); } } @@ -689,8 +690,8 @@ async function issueRemotePairingToken( ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); - const stdout = result.stdout.trim(); - if (!stdout) { + const line = getLastNonEmptyOutputLine(result.stdout); + if (!line) { throw new Error( `SSH pairing did not return a credential. stdout=${JSON.stringify(result.stdout)}`, ); @@ -698,10 +699,10 @@ async function issueRemotePairingToken( let parsed: { credential?: unknown }; try { - parsed = JSON.parse(stdout) as { credential?: unknown }; + parsed = JSON.parse(line) as { credential?: unknown }; } catch (cause) { throw new Error( - `SSH pairing returned unparseable output. stdout=${JSON.stringify(result.stdout)}`, + `SSH pairing returned unparseable output. line=${JSON.stringify(line)} stdout=${JSON.stringify(result.stdout)}`, { cause }, ); } @@ -768,8 +769,13 @@ async function stopTunnel(entry: SshTunnelEntry): Promise { export async function discoverDesktopSshHosts(input?: { readonly homeDir?: string; }): Promise { - const sshDirectory = Path.join(input?.homeDir ?? OS.homedir(), ".ssh"); - const configAliases = collectSshConfigAliasesFromFile(Path.join(sshDirectory, "config")); + const homeDir = input?.homeDir ?? OS.homedir(); + const sshDirectory = Path.join(homeDir, ".ssh"); + const configAliases = collectSshConfigAliasesFromFile( + Path.join(sshDirectory, "config"), + new Set(), + homeDir, + ); const knownHosts = readKnownHostsHostnames(Path.join(sshDirectory, "known_hosts")); const discovered = new Map(); @@ -803,8 +809,11 @@ export class DesktopSshEnvironmentManager { private readonly tunnels = new Map(); private readonly pendingTunnelEntries = new Map>(); private readonly authSecrets = new Map(); + private readonly options: DesktopSshEnvironmentManagerOptions; - constructor(private readonly options: DesktopSshEnvironmentManagerOptions = {}) {} + constructor(options: DesktopSshEnvironmentManagerOptions = {}) { + this.options = options; + } private deleteTunnelIfCurrent(entry: SshTunnelEntry): void { if (this.tunnels.get(entry.key) === entry) { @@ -886,7 +895,12 @@ export class DesktopSshEnvironmentManager { target: DesktopSshEnvironmentTarget, options?: { readonly issuePairingToken?: boolean }, ): Promise { - const resolvedTarget = await resolveDesktopSshTarget(target.alias || target.hostname); + const baseResolved = await resolveDesktopSshTarget(target.alias || target.hostname); + const resolvedTarget: DesktopSshEnvironmentTarget = { + ...baseResolved, + ...(target.username !== null ? { username: target.username } : {}), + ...(target.port !== null ? { port: target.port } : {}), + }; const key = targetConnectionKey(resolvedTarget); const packageSpec = this.options.resolveCliPackageSpec?.(); const entry = await this.ensureTunnelEntry(key, resolvedTarget, packageSpec); @@ -1003,7 +1017,7 @@ export class DesktopSshEnvironmentManager { }); waitForHttpReady(httpBaseUrl, { timeoutMs: SSH_READY_TIMEOUT_MS }) .then(() => resolve()) - .catch((error) => reject(error)); + .catch((error: unknown) => reject(error)); }); this.tunnels.set(key, tunnelEntry); try { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 47e159d3036..61795bdd854 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -209,10 +209,8 @@ const browserOtlpTracingLayer = Layer.mergeAll( Layer.succeed(HttpClient.TracerDisabledWhen, () => true), ); -const authTestLayer = ServerAuthLive.pipe( - Layer.provide(SqlitePersistenceMemory), - Layer.provide(ServerSecretStoreLive), -); +const makeAuthTestLayer = () => + ServerAuthLive.pipe(Layer.provide(SqlitePersistenceMemory), Layer.provide(ServerSecretStoreLive)); const makeBrowserOtlpPayload = (spanName: string) => Effect.gen(function* () { @@ -539,7 +537,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.repositoryIdentityResolver, }), ), - Layer.provideMerge(authTestLayer), + Layer.provideMerge(makeAuthTestLayer()), Layer.provide(workspaceAndProjectServicesLayer), Layer.provideMerge(FetchHttpClient.layer), Layer.provide(layerConfig), diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 336424a7044..cb3cb0ee874 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -938,12 +938,16 @@ describe("GeneralSettingsPanel observability", () => { ); await page.getByRole("button", { name: "Add environment", exact: true }).click(); - await expect.element(page.getByText("Add Environment")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "Add Environment", exact: true })) + .toBeInTheDocument(); await page.getByRole("button", { name: "SSH", exact: true }).click(); await vi.waitFor(() => { expect(discoverSshHosts).toHaveBeenCalledTimes(1); }); - await expect.element(page.getByText("devbox")).toBeInTheDocument(); + await expect + .element(page.getByRole("heading", { name: "devbox", exact: true })) + .toBeInTheDocument(); await page.getByText("Enter a host manually").click(); await page.getByLabelText("Label").fill("Build box"); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b3181504fb7..f6cdbacfa7c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -60,7 +60,8 @@ import type { import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import type { ClientSettings } from "./settings.ts"; +import { ServerSettings, ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; From c5c99a766c20c4710cc0c5cf44d3f718a7424e4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 26 Apr 2026 19:57:23 -0700 Subject: [PATCH 19/19] docs(remote): document desktop ssh launch Co-authored-by: codex --- .docs/remote-architecture.md | 11 +++++++++++ REMOTE.md | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 89b3d41ea0c..75274095a12 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -252,6 +252,8 @@ After that, the renderer should still connect using an ordinary WebSocket URL ag This keeps the renderer transport model consistent with every other access method. +The desktop main process owns the SSH bridge because it can spawn local SSH processes, manage askpass prompts, write temporary launch scripts, and clean up forwards. The renderer receives a saved environment record and connects through the forwarded URL; it should not need SSH-specific RPC paths for normal environment traffic. + ## Launch methods Launch methods answer a different question: @@ -294,6 +296,15 @@ The recommended T3 flow is: 4. Desktop establishes local port forwarding. 5. Renderer connects to the forwarded WebSocket endpoint as a normal environment. +The saved environment should remember that it was created by desktop SSH launch only for reconnect and lifecycle UX. That metadata should not change the server protocol or the environment identity model. + +Failure handling should be explicit: + +- SSH authentication failure should surface before any environment is saved +- remote launch failure should include remote logs or the launcher command output when available +- forwarded-port failure should leave the saved environment disconnected rather than falling back to an unrelated endpoint +- reconnect should attempt to restore the SSH bridge before reconnecting the normal WebSocket client + ### 3. Client-managed local publish This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel. diff --git a/REMOTE.md b/REMOTE.md index afe22bd6040..2d7edb1c15e 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -80,6 +80,20 @@ Use `t3 serve --help` for the full flag reference. It supports the same general > For now, use `t3 project ...` on the server machine instead. > Full GUI support for remote project management is coming soon. +### Option 3: Desktop-Managed SSH Launch + +Use this when you want the desktop app to start or reuse T3 Code on another machine over SSH. + +1. Open **Settings** → **Connections**. +2. Under **Remote Environments**, choose **Add environment**. +3. Select the SSH launch flow. +4. Enter the SSH target, such as `user@example.com`. +5. Confirm the launch. The desktop app probes the host, starts or reuses a remote T3 server, opens a local port forward, and saves the environment. + +After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. The remote host still owns the actual T3 server, projects, files, git state, terminals, and provider sessions. + +SSH launch is a desktop feature because it needs local process and SSH access. Once the environment is paired and saved, it uses the same environment list and connection model as direct LAN, Tailscale, HTTPS, or future tunnel-backed environments. + ## How Pairing Works The remote device does not need a long-lived secret up front.