diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 8e5ed37928e..75274095a12 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -110,6 +110,47 @@ A single environment may have many endpoints: The environment stays the same. Only the access path changes. +### AdvertisedEndpoint + +An `AdvertisedEndpoint` is a server or desktop-authored candidate endpoint for an environment. It is how the backend tells the client which URLs may be useful for pairing and reconnecting. + +`AdvertisedEndpoint` is deliberately narrower than the full access model: + +- it describes a concrete HTTP and WebSocket base URL pair +- it can mark the endpoint as default, available, or unavailable +- it includes reachability hints such as loopback, LAN, private, public, or tunnel +- it includes compatibility hints such as whether the endpoint can be used from the hosted HTTPS app + +Clients should treat advertised endpoints as hints, not as proof that a route works from the current device. The final connection attempt still decides whether the endpoint is reachable. + +The UI presents one default advertised endpoint in the network-access summary and keeps the rest behind an expandable advanced list. The default controls pairing QR codes and primary copy actions. Users can override it, but that override is a UI preference, not backend configuration. + +Persist the override by stable endpoint kind rather than raw URL whenever possible. For example, a LAN endpoint should be stored as the desktop LAN endpoint preference, not as `192.168.x.y`, because the address can change when the user switches networks. Provider endpoints should use provider-specific stable keys such as Tailscale IP or Tailscale MagicDNS HTTPS. Custom endpoints may fall back to their concrete identity. + +When no user default is saved, endpoint selection should prefer: + +1. endpoints compatible with the hosted HTTPS app +2. explicitly default endpoints +3. non-loopback endpoints +4. loopback endpoints only for same-machine clients + +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. @@ -194,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. @@ -209,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: @@ -251,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 cec7b0ecccb..2d7edb1c15e 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -22,9 +22,33 @@ If you are already running the desktop app and want to make it reachable from ot 1. Open **Settings** → **Connections**. 2. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces. -3. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`). +3. The settings panel will show the default reachable endpoint, with a `+N` control when more endpoints are available. Expand it to inspect alternatives such as loopback, LAN, private-network, or HTTPS endpoints. 4. Use **Create Link** to generate a pairing link you can share with another device. +The default endpoint controls the QR code and primary copy action for pairing links. You can change it from the expanded endpoint list. The preference is stored by endpoint type, so choosing the local LAN endpoint survives normal IP address changes when you move between networks. + +When no user default is saved, the app chooses the best reachable endpoint for pairing links: + +- HTTPS/WSS-compatible endpoints are preferred because they work from `https://app.t3.codes`. +- Non-loopback HTTP endpoints are used for direct LAN pairing when HTTPS is not available. +- Loopback-only endpoints are not useful for another device unless that device is the same machine. + +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. @@ -56,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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 165395507ef..316228fd1a3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "electron-updater": "^6.6.2" }, "devDependencies": { + "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@types/node": "catalog:", 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 c5507c6fb03..74baa16c54d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -55,7 +55,11 @@ import { } from "./clientPersistence.ts"; import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts"; import { showDesktopConfirmDialog } from "./confirmDialog.ts"; -import { resolveDesktopServerExposure } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, +} from "./serverExposure.ts"; +import { DesktopSshEnvironmentBridge, resolveRemoteT3CliPackageSpec } from "./sshEnvironment.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; @@ -76,6 +80,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(); @@ -102,6 +107,7 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; 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"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -297,6 +303,7 @@ function backendChildEnv(): NodeJS.ProcessEnv { delete env.T3CODE_DESKTOP_WS_URL; delete env.T3CODE_DESKTOP_LAN_ACCESS; delete env.T3CODE_DESKTOP_LAN_HOST; + delete env.T3CODE_DESKTOP_HTTPS_ENDPOINTS; return env; } @@ -308,6 +315,25 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } +async function getDesktopAdvertisedEndpoints() { + const exposure = resolveDesktopServerExposure({ + mode: desktopServerExposureMode, + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), + }); + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ + port: backendPort, + exposure, + customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), + }); + const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + }); + return [...coreEndpoints, ...tailscaleEndpoints]; +} + function getDesktopSecretStorage() { return { isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(), @@ -321,6 +347,13 @@ function resolveAdvertisedHostOverride(): string | undefined { return override && override.length > 0 ? override : undefined; } +function resolveCustomHttpsEndpointUrls(): readonly string[] { + return (process.env.T3CODE_DESKTOP_HTTPS_ENDPOINTS ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + async function applyDesktopServerExposureMode( mode: DesktopServerExposureMode, options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean }, @@ -373,6 +406,7 @@ function relaunchDesktopApp(reason: string): void { `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`, ); }) + .then(() => desktopSshEnvironmentBridge.dispose().catch(() => undefined)) .finally(() => { restoreStdIoCapture?.(); if (isDevelopment) { @@ -629,6 +663,16 @@ let updateInstallInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +const desktopSshEnvironmentBridge = new DesktopSshEnvironmentBridge({ + getMainWindow: () => mainWindow, + resolveCliPackageSpec: () => + resolveRemoteT3CliPackageSpec({ + appVersion: app.getVersion(), + updateChannel: desktopSettings.updateChannel, + isDevelopment, + }), +}); + function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateInstallInFlight) return "install"; if (updateDownloadInFlight) return "download"; @@ -1647,6 +1691,8 @@ function registerIpcHandlers(): void { }, ); + desktopSshEnvironmentBridge.registerIpcHandlers(ipcMain); + ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL); ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState()); @@ -1669,6 +1715,9 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(GET_ADVERTISED_ENDPOINTS_CHANNEL); + ipcMain.handle(GET_ADVERTISED_ENDPOINTS_CHANNEL, async () => getDesktopAdvertisedEndpoints()); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -2019,6 +2068,9 @@ function createWindow(): BrowserWindow { } window.on("closed", () => { + desktopSshEnvironmentBridge.cancelPendingPasswordPrompts( + "SSH authentication was cancelled because the app window closed.", + ); if (mainWindow === window) { mainWindow = null; } @@ -2110,6 +2162,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); @@ -2159,6 +2212,7 @@ if (process.platform !== "win32") { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); @@ -2169,6 +2223,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGTERM received"); clearUpdatePollTimer(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a6756048725..fdfaf20813f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -22,8 +22,17 @@ 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"; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -51,8 +60,33 @@ 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), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/desktop/src/serverExposure.test.ts b/apps/desktop/src/serverExposure.test.ts index c83bbc210e0..86e9d3a6558 100644 --- a/apps/desktop/src/serverExposure.test.ts +++ b/apps/desktop/src/serverExposure.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts"; +import { + resolveDesktopCoreAdvertisedEndpoints, + resolveDesktopServerExposure, + resolveLanAdvertisedHost, +} from "./serverExposure.ts"; describe("resolveLanAdvertisedHost", () => { it("prefers an explicit host override", () => { @@ -74,6 +78,97 @@ describe("resolveLanAdvertisedHost", () => { }); }); +describe("resolveDesktopCoreAdvertisedEndpoints", () => { + it("advertises loopback and LAN endpoints without provider-specific assumptions", () => { + const exposure = resolveDesktopServerExposure({ + mode: "network-accessible", + port: 3773, + networkInterfaces: { + en0: [ + { + address: "192.168.1.44", + family: "IPv4", + internal: false, + netmask: "255.255.255.0", + cidr: "192.168.1.44/24", + mac: "00:00:00:00:00:00", + }, + ], + }, + }); + + expect( + resolveDesktopCoreAdvertisedEndpoints({ + port: 3773, + exposure, + customHttpsEndpointUrls: ["https://desktop.example.ts.net"], + }), + ).toEqual([ + { + id: "desktop-loopback:3773", + label: "This machine", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://127.0.0.1:3773/", + wsBaseUrl: "ws://127.0.0.1:3773/", + reachability: "loopback", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + description: "Loopback endpoint for this desktop app.", + }, + { + id: "desktop-lan:http://192.168.1.44:3773", + label: "Local network", + provider: { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, + }, + httpBaseUrl: "http://192.168.1.44:3773/", + wsBaseUrl: "ws://192.168.1.44:3773/", + reachability: "lan", + compatibility: { + hostedHttpsApp: "mixed-content-blocked", + desktopApp: "compatible", + }, + source: "desktop-core", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }, + { + id: "manual:https://desktop.example.ts.net", + label: "Custom HTTPS", + provider: { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, + }, + httpBaseUrl: "https://desktop.example.ts.net/", + wsBaseUrl: "wss://desktop.example.ts.net/", + reachability: "public", + compatibility: { + hostedHttpsApp: "compatible", + desktopApp: "compatible", + }, + source: "user", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }, + ]); + }); +}); + describe("resolveDesktopServerExposure", () => { it("keeps the desktop server loopback-only when local-only mode is selected", () => { expect( diff --git a/apps/desktop/src/serverExposure.ts b/apps/desktop/src/serverExposure.ts index 65c99b60e13..fb6b284b9ef 100644 --- a/apps/desktop/src/serverExposure.ts +++ b/apps/desktop/src/serverExposure.ts @@ -1,5 +1,13 @@ import type { NetworkInterfaceInfo } from "node:os"; -import type { DesktopServerExposureMode } from "@t3tools/contracts"; +import { + createAdvertisedEndpoint, + type CreateAdvertisedEndpointInput, +} from "@t3tools/client-runtime"; +import type { + AdvertisedEndpoint, + AdvertisedEndpointProvider, + DesktopServerExposureMode, +} from "@t3tools/contracts"; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST = "0.0.0.0"; @@ -13,6 +21,26 @@ export interface DesktopServerExposure { readonly advertisedHost: string | null; } +export interface DesktopAdvertisedEndpointInput { + readonly port: number; + readonly exposure: DesktopServerExposure; + readonly customHttpsEndpointUrls?: readonly string[]; +} + +const DESKTOP_CORE_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "desktop-core", + label: "Desktop", + kind: "core", + isAddon: false, +}; + +const DESKTOP_MANUAL_ENDPOINT_PROVIDER: AdvertisedEndpointProvider = { + id: "manual", + label: "Manual", + kind: "manual", + isAddon: false, +}; + const normalizeOptionalHost = (value: string | undefined): string | undefined => { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; @@ -78,3 +106,68 @@ export function resolveDesktopServerExposure(input: { advertisedHost, }; } + +function createDesktopEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_CORE_ENDPOINT_PROVIDER, + source: "desktop-core", + }); +} + +function createManualEndpoint( + input: Omit, +): AdvertisedEndpoint { + return createAdvertisedEndpoint({ + ...input, + provider: DESKTOP_MANUAL_ENDPOINT_PROVIDER, + source: "user", + }); +} + +export function resolveDesktopCoreAdvertisedEndpoints( + input: DesktopAdvertisedEndpointInput, +): readonly AdvertisedEndpoint[] { + const endpoints: AdvertisedEndpoint[] = [ + createDesktopEndpoint({ + id: `desktop-loopback:${input.port}`, + label: "This machine", + httpBaseUrl: input.exposure.localHttpUrl, + reachability: "loopback", + status: "available", + description: "Loopback endpoint for this desktop app.", + }), + ]; + + if (input.exposure.endpointUrl) { + endpoints.push( + createDesktopEndpoint({ + id: `desktop-lan:${input.exposure.endpointUrl}`, + label: "Local network", + httpBaseUrl: input.exposure.endpointUrl, + reachability: "lan", + status: "available", + isDefault: true, + description: "Reachable from devices on the same network.", + }), + ); + } + + for (const customEndpointUrl of input.customHttpsEndpointUrls ?? []) { + endpoints.push( + createManualEndpoint({ + id: `manual:${customEndpointUrl}`, + label: "Custom HTTPS", + httpBaseUrl: customEndpointUrl, + reachability: "public", + hostedHttpsCompatibility: "compatible", + status: "unknown", + description: "User-configured HTTPS endpoint for this desktop backend.", + }), + ); + } + + return endpoints; +} diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts new file mode 100644 index 00000000000..fe7274b8b90 --- /dev/null +++ b/apps/desktop/src/sshEnvironment.test.ts @@ -0,0 +1,310 @@ +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, vi } from "vitest"; + +import { __test, discoverDesktopSshHosts } from "./sshEnvironment.ts"; + +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", + "port.example.com:22 ssh-ed25519 HHHH", + "::1 ssh-ed25519 FFFF", + "2001:db8::1 ssh-ed25519 GGGG", + "", + ].join("\n"), + ), + ).toEqual([ + "::1", + "2001:db8::1", + "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( + "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@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@0.0.17-nightly.20260415.44"); + expect( + __test.resolveRemoteT3CliPackageSpec({ + appVersion: "0.0.0-dev", + updateChannel: "nightly", + 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", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + 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.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", () => { + 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( + 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); + 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 new file mode 100644 index 00000000000..16a75dc8cb6 --- /dev/null +++ b/apps/desktop/src/sshEnvironment.ts @@ -0,0 +1,1377 @@ +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 { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, + DesktopDiscoveredSshHost, + DesktopSshEnvironmentBootstrap, + DesktopSshEnvironmentTarget, + DesktopSshPasswordPromptRequest, + DesktopUpdateChannel, + ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; + +import { waitForHttpReady } from "./backendReadiness.ts"; + +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; +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; + 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; + readonly resolveCliPackageSpec?: () => string; +} + +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 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(Path.join(homeDir, ".ssh"), expandedPattern); +} + +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(), + homeDir: string = OS.homedir(), +): 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 = resolveSshConfigIncludePattern(includePattern, directory, homeDir); + for (const includedPath of expandGlob(resolvedPattern)) { + for (const alias of collectSshConfigAliasesFromFile(includedPath, visited, homeDir)) { + 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 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(); + + 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 = normalizeKnownHostsHostname(rawHost).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); +} + +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 readSshScriptTemplate("askpass-posix.sh"); +} + +function buildWindowsSshAskpassScript(): string { + return toCrlf(readSshScriptTemplate("askpass-windows.ps1")); +} + +function buildWindowsSshAskpassLauncherScript(): string { + return toCrlf(readSshScriptTemplate("askpass-windows.cmd")); +} + +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: buildWindowsSshAskpassLauncherScript(), + }, + { + 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); + 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) + ); +} + +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(input?: { readonly packageSpec?: string }): string { + 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), + }); +} + +function getLastNonEmptyOutputLine(stdout: string): string | null { + return ( + stdout + .trim() + .split(/\r?\n/u) + .map((entry) => entry.trim()) + .findLast((entry) => entry.length > 0) ?? null + ); +} + +export function resolveRemoteT3CliPackageSpec(input: { + readonly appVersion: string; + readonly updateChannel: DesktopUpdateChannel; + readonly isDevelopment?: boolean; +}): string { + const appVersion = input.appVersion.trim(); + if (!input.isDevelopment && PUBLISHABLE_T3_VERSION_PATTERN.test(appVersion)) { + return `t3@${appVersion}`; + } + + if (input.isDevelopment) { + return "t3@nightly"; + } + + return input.updateChannel === "nightly" ? "t3@nightly" : "t3@latest"; +} + +function buildRemoteT3RunnerScript(input?: { readonly packageSpec?: string }): string { + const packageSpec = input?.packageSpec?.trim() || "t3@latest"; + return stripTrailingNewlines( + applyScriptPlaceholders(readSshScriptTemplate("remote-runner.sh"), { + T3_PACKAGE_SPEC: packageSpec, + }), + ); +} + +function buildRemotePairingScript( + target: DesktopSshEnvironmentTarget, + input?: { readonly packageSpec?: string }, +): string { + return applyScriptPlaceholders(readSshScriptTemplate("remote-pairing.sh"), { + T3_STATE_KEY: remoteStateKey(target), + T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), + }); +} + +async function launchOrReuseRemoteServer( + target: DesktopSshEnvironmentTarget, + input?: SshAuthOptions, + runner?: { readonly packageSpec?: string }, +): Promise { + const result = await runSshCommand(target, { + remoteCommandArgs: ["sh", "-s", "--", remoteStateKey(target)], + stdin: buildRemoteLaunchScript(runner), + ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), + ...(input?.batchMode === undefined ? {} : { batchMode: input.batchMode }), + ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), + }); + const line = getLastNonEmptyOutputLine(result.stdout); + if (!line) { + throw new Error( + `SSH launch did not return a remote port. stdout=${JSON.stringify(result.stdout)}`, + ); + } + + 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. parsed=${JSON.stringify(parsed)} stdout=${JSON.stringify(result.stdout)}`, + ); + } + return parsed.remotePort; +} + +async function issueRemotePairingToken( + target: DesktopSshEnvironmentTarget, + input?: SshAuthOptions, + runner?: { readonly packageSpec?: string }, +): Promise { + const result = await runSshCommand(target, { + remoteCommandArgs: ["sh", "-s"], + stdin: buildRemotePairingScript(target, runner), + ...(input?.authSecret === undefined ? {} : { authSecret: input.authSecret }), + ...(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. stdout=${JSON.stringify(result.stdout)}`, + ); + } + + let parsed: { credential?: unknown }; + try { + parsed = JSON.parse(line) as { credential?: unknown }; + } catch (cause) { + throw new Error( + `SSH pairing returned unparseable output. line=${JSON.stringify(line)} 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. parsed=${JSON.stringify(parsed)} stdout=${JSON.stringify(result.stdout)}`, + ); + } + 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; + let hardStopTimer: ReturnType | null = null; + + const settle = () => { + if (settled) { + return; + } + settled = true; + child.off("exit", onExit); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + if (hardStopTimer) { + clearTimeout(hardStopTimer); + } + resolve(); + }; + + const onExit = () => { + settle(); + }; + + child.once("exit", onExit); + 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(); + }); +} + +export async function discoverDesktopSshHosts(input?: { + readonly homeDir?: string; +}): Promise { + 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(); + + 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 pendingTunnelEntries = new Map>(); + private readonly authSecrets = new Map(); + private readonly options: DesktopSshEnvironmentManagerOptions; + + constructor(options: DesktopSshEnvironmentManagerOptions = {}) { + this.options = options; + } + + private deleteTunnelIfCurrent(entry: SshTunnelEntry): void { + if (this.tunnels.get(entry.key) === entry) { + this.tunnels.delete(entry.key); + } + } + + 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 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); + + 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.deleteTunnelIfCurrent(entry); + 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, + authOptions, + packageSpec === undefined ? undefined : { packageSpec }, + ), + ); + const localPort = await findAvailableLocalPort(); + const httpBaseUrl = `http://127.0.0.1:${localPort}/`; + const wsBaseUrl = `ws://127.0.0.1:${localPort}/`; + return 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 tunnelEntry: 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.deleteTunnelIfCurrent(tunnelEntry); + reject(error); + }); + process.once("exit", (code) => { + this.deleteTunnelIfCurrent(tunnelEntry); + 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: unknown) => reject(error)); + }); + this.tunnels.set(key, tunnelEntry); + try { + await tunnelReady; + return tunnelEntry; + } catch (error) { + await stopTunnel(tunnelEntry).catch(() => undefined); + this.deleteTunnelIfCurrent(tunnelEntry); + throw error; + } + }); + })(); + 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))); + } +} + +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, + buildRemotePairingScript, + buildRemoteT3RunnerScript, + resolveRemoteT3CliPackageSpec, + buildSshAskpassHelperDescriptor, + buildSshChildEnvironment, + getLastNonEmptyOutputLine, + isSshAuthFailure, + collectSshConfigAliasesFromFile, + expandHomePath, + normalizeKnownHostsHostname, + parseKnownHostsHostnames, + parseSshResolveOutput, + resolveSshConfigIncludePattern, + stopTunnel, +}; diff --git a/apps/desktop/src/sshScripts/askpass-posix.sh b/apps/desktop/src/sshScripts/askpass-posix.sh new file mode 100644 index 00000000000..56e3bc5b253 --- /dev/null +++ b/apps/desktop/src/sshScripts/askpass-posix.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# 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 +printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\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..c24cfc21998 --- /dev/null +++ b/apps/desktop/src/sshScripts/askpass-windows.ps1 @@ -0,0 +1,10 @@ +# 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 +} +[Console]::Error.WriteLine("T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.") +exit 1 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/src/tailscaleEndpointProvider.test.ts b/apps/desktop/src/tailscaleEndpointProvider.test.ts new file mode 100644 index 00000000000..d228a2c3bf1 --- /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:3773", + label: "Tailscale HTTPS", + provider: { + id: "tailscale", + label: "Tailscale", + kind: "private-network", + isAddon: true, + }, + httpBaseUrl: "https://desktop.tail.ts.net:3773/", + wsBaseUrl: "wss://desktop.tail.ts.net:3773/", + 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..75e3096d6b2 --- /dev/null +++ b/apps/desktop/src/tailscaleEndpointProvider.ts @@ -0,0 +1,158 @@ +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; + readonly port: number; +}): AdvertisedEndpoint | null { + if (!input.dnsName) { + return null; + } + + const httpBaseUrl = `https://${input.dnsName}:${input.port}`; + return createTailscaleEndpoint({ + id: `tailscale-magicdns:${httpBaseUrl}`, + label: "Tailscale HTTPS", + httpBaseUrl, + 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, + port: input.port, + }); + + return magicDnsEndpoint ? [...ipEndpoints, magicDnsEndpoint] : ipEndpoints; +} 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, 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/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 f6ab384d9fb..95e504207d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,8 +1,11 @@ -import { PlusIcon, QrCodeIcon } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "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"; @@ -29,6 +32,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; +import { ScrollArea } from "../ui/scroll-area"; import { AlertDialog, AlertDialogClose, @@ -45,6 +49,8 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { Group, GroupSeparator } from "../ui/group"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Textarea } from "../ui/textarea"; import { setPairingTokenOnUrl } from "../../pairingUrl"; import { @@ -64,10 +70,12 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + connectDesktopSshEnvironment, getPrimaryEnvironmentConnection, reconnectSavedEnvironment, removeSavedEnvironment, } from "~/environments/runtime"; +import { useUiStateStore } from "~/uiStateStore"; const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -161,8 +169,72 @@ 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"; const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; @@ -244,6 +316,64 @@ function removeDesktopClientSession( return current.filter((clientSession) => clientSession.sessionId !== sessionId); } +function selectPairingEndpoint( + endpoints: ReadonlyArray, + defaultEndpointKey?: string | null, +): AdvertisedEndpoint | null { + const availableEndpoints = endpoints.filter((endpoint) => endpoint.status !== "unavailable"); + if (defaultEndpointKey) { + const selectedEndpoint = availableEndpoints.find( + (endpoint) => endpointDefaultPreferenceKey(endpoint) === defaultEndpointKey, + ); + if (selectedEndpoint) { + return selectedEndpoint; + } + } + return ( + availableEndpoints.find((endpoint) => endpoint.compatibility.hostedHttpsApp === "compatible") ?? + availableEndpoints.find((endpoint) => endpoint.isDefault) ?? + availableEndpoints.find((endpoint) => endpoint.reachability !== "loopback") ?? + null + ); +} + +function endpointDefaultPreferenceKey(endpoint: AdvertisedEndpoint): string { + if (endpoint.id.startsWith("desktop-loopback:")) { + return "desktop-core:loopback:http"; + } + if (endpoint.id.startsWith("desktop-lan:")) { + return "desktop-core:lan:http"; + } + if (endpoint.id.startsWith("tailscale-ip:")) { + return "tailscale:ip:http"; + } + if (endpoint.id.startsWith("tailscale-magicdns:")) { + return "tailscale:magicdns:https"; + } + + let scheme = "unknown"; + try { + scheme = new URL(endpoint.httpBaseUrl).protocol.replace(/:$/u, ""); + } catch { + // Keep the stored preference stable even if a custom endpoint is malformed. + } + + return `${endpoint.provider.id}:${endpoint.reachability}:${scheme}:${endpoint.label}`; +} + +function resolveAdvertisedEndpointPairingUrl( + endpoint: AdvertisedEndpoint, + credential: string, +): string { + if (endpoint.compatibility.hostedHttpsApp === "compatible") { + return ( + resolveHostedPairingUrl(endpoint.httpBaseUrl, credential) ?? + resolveDesktopPairingUrl(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(); @@ -252,6 +382,8 @@ function resolveCurrentOriginPairingUrl(credential: string): string { type PairingLinkListRowProps = { pairingLink: ServerPairingLinkRecord; endpointUrl: string | null | undefined; + endpoints: ReadonlyArray; + defaultEndpointKey: string | null; revokingPairingLinkId: string | null; onRevoke: (id: string) => void; }; @@ -259,6 +391,8 @@ type PairingLinkListRowProps = { const PairingLinkListRow = memo(function PairingLinkListRow({ pairingLink, endpointUrl, + endpoints, + defaultEndpointKey, revokingPairingLinkId, onRevoke, }: PairingLinkListRowProps) { @@ -280,43 +414,76 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ : null, [endpointUrl, pairingLink.credential], ); + const endpointPairingUrl = useMemo(() => { + const endpoint = selectPairingEndpoint(endpoints, defaultEndpointKey); + return endpoint ? resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential) : null; + }, [defaultEndpointKey, endpoints, pairingLink.credential]); + const endpointCopyOptions = useMemo( + () => + endpoints + .filter((endpoint) => endpoint.status !== "unavailable") + .map((endpoint) => ({ + key: endpointDefaultPreferenceKey(endpoint), + label: endpoint.label, + url: resolveAdvertisedEndpointPairingUrl(endpoint, pairingLink.credential), + })), + [endpoints, pairingLink.credential], + ); const shareablePairingUrl = - endpointUrl != null && endpointUrl !== "" + endpointPairingUrl ?? + (endpointUrl != null && endpointUrl !== "" ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) : isLoopbackHostname(window.location.hostname) ? null - : currentOriginPairingUrl; - const copyValue = shareablePairingUrl ?? pairingLink.credential; + : currentOriginPairingUrl); + const revealValue = shareablePairingUrl ?? pairingLink.credential; const canCopyToClipboard = typeof window !== "undefined" && window.isSecureContext && navigator.clipboard?.writeText != null; - const { copyToClipboard, isCopied } = useCopyToClipboard({ - onCopy: () => { + const { copyToClipboard } = useCopyToClipboard<"code" | "link">({ + onCopy: (kind) => { toastManager.add({ type: "success", - title: shareablePairingUrl ? "Pairing URL copied" : "Pairing token copied", - description: shareablePairingUrl - ? "Open it in the client you want to pair to this environment." - : "Paste it into another client with this backend's reachable host.", + title: kind === "link" ? "Pairing URL copied" : "Pairing code copied", + description: + kind === "link" + ? "Open it in the client you want to pair to this environment." + : "Paste it into another client to finish pairing.", }); }, - onError: (error) => { + onError: (error, kind) => { setIsRevealDialogOpen(true); toastManager.add( stackedThreadToast({ type: "error", - title: canCopyToClipboard ? "Could not copy pairing URL" : "Clipboard copy unavailable", + title: canCopyToClipboard + ? kind === "link" + ? "Could not copy pairing URL" + : "Could not copy pairing code" + : "Clipboard copy unavailable", description: canCopyToClipboard ? error.message : "Showing the full value instead.", }), ); }, }); - const handleCopy = useCallback(() => { - copyToClipboard(copyValue, undefined); - }, [copyToClipboard, copyValue]); + const copyPairingValue = useCallback( + (value: string, kind: "code" | "link") => { + copyToClipboard(value, kind); + }, + [copyToClipboard], + ); + + const handleCopyCode = useCallback(() => { + copyPairingValue(pairingLink.credential, "code"); + }, [copyPairingValue, pairingLink.credential]); + + const handleCopyDefaultLink = useCallback(() => { + if (!shareablePairingUrl) return; + copyPairingValue(shareablePairingUrl, "link"); + }, [copyPairingValue, shareablePairingUrl]); const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); @@ -379,27 +546,71 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
{canCopyToClipboard ? ( - + <> + + {shareablePairingUrl ? ( + endpointCopyOptions.length > 1 ? ( + + + + + + } + > + + + + {endpointCopyOptions.map((option) => ( + copyPairingValue(option.url, "link")} + > + + {option.label} + + Pairing URL + + + + ))} + + + + ) : ( + + ) + ) : null} + ) : ( }> - {shareablePairingUrl ? "Show link" : "Show token"} + {shareablePairingUrl ? "Show link" : "Show code"} )} - {shareablePairingUrl ? "Pairing link" : "Pairing token"} + {shareablePairingUrl ? "Pairing link" : "Pairing code"} {shareablePairingUrl ? "Clipboard copy is unavailable here. Open or manually copy this full pairing URL on the device you want to connect." - : "Clipboard copy is unavailable here. Manually copy this token and pair from another client using this backend's reachable host."} + : "Clipboard copy is unavailable here. Manually copy this code into another client."}