diff --git a/.docs/remote-architecture.md b/.docs/remote-architecture.md index 1748df5db65..75274095a12 100644 --- a/.docs/remote-architecture.md +++ b/.docs/remote-architecture.md @@ -136,6 +136,21 @@ When no user default is saved, endpoint selection should prefer: This keeps endpoint discovery centralized without making any one provider, such as Tailscale or a future tunnel service, part of the core environment model. +### Endpoint providers + +Endpoint providers are add-ons that contribute advertised endpoints for the current environment. + +The provider boundary is intentionally outside the core environment model: + +- core owns `ExecutionEnvironment`, saved environments, pairing, and connection lifecycle +- providers discover or synthesize endpoints +- providers return normalized `AdvertisedEndpoint` records +- the UI and pairing logic select from those records without knowing provider-specific commands + +The first provider is Tailscale. It can discover Tailnet IP and MagicDNS addresses from the local machine and publish them as additional endpoint candidates. Future providers, such as a hosted tunnel service, should plug into the same shape rather than adding a separate remote environment path. + +Provider-specific confidence should remain a hint. A Tailscale endpoint still needs a successful browser or desktop connection before the client treats it as connected. + ### Hosted pairing request A hosted pairing request is a bootstrap URL for the static web app, not a transport. @@ -220,6 +235,8 @@ This is especially useful when: - mobile must reach a desktop-hosted environment - a machine should be reachable without exposing raw LAN or public ports +Tailscale-backed access sits here architecturally even though the current implementation is endpoint discovery rather than a T3-managed tunnel. It contributes private-network endpoints and lets the existing HTTP/WebSocket client path do the actual connection. + ### 3. Desktop-managed SSH access SSH is an access and launch helper, not a separate environment type. @@ -235,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: @@ -277,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 f5ddccaa85a..2d7edb1c15e 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -35,6 +35,20 @@ When no user default is saved, the app chooses the best reachable endpoint for p If the copied link points directly at `http://192.168.x.y:3773`, open it from a client that can reach that LAN address. If it points at `https://app.t3.codes/pair?...`, the hosted web app will save the environment and connect directly to the backend URL in the link. +### Tailscale Endpoints + +When the desktop app can detect Tailscale, it adds Tailnet endpoints to the reachable endpoint list. + +Depending on your Tailscale setup, this may include: + +- the machine's `100.x.y.z` Tailnet IP +- a MagicDNS name +- an HTTPS MagicDNS endpoint when Tailscale HTTPS is available for the machine + +The Tailscale support is an endpoint provider add-on. The core remote model still works without Tailscale: LAN HTTP endpoints, custom HTTPS endpoints, future tunnels, and SSH-launched environments all use the same saved environment and pairing flow. + +For `https://app.t3.codes`, prefer an HTTPS Tailnet or other HTTPS endpoint. A plain `http://100.x.y.z:3773` endpoint can still work from a desktop client or another browser page served over HTTP, but it will not work from the hosted HTTPS app because of browser mixed-content rules. + ### Option 2: Headless Server (CLI) Use this when you want to run the server without a GUI, for example on a remote machine over SSH. @@ -66,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/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 c5713d2035b..74baa16c54d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -59,6 +59,7 @@ 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"; @@ -79,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(); @@ -313,18 +315,23 @@ function getDesktopServerExposureState(): DesktopServerExposureState { }; } -function getDesktopAdvertisedEndpoints() { +async function getDesktopAdvertisedEndpoints() { const exposure = resolveDesktopServerExposure({ mode: desktopServerExposureMode, port: backendPort, networkInterfaces: OS.networkInterfaces(), ...(backendAdvertisedHost ? { advertisedHostOverride: backendAdvertisedHost } : {}), }); - return resolveDesktopCoreAdvertisedEndpoints({ + const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: backendPort, exposure, customHttpsEndpointUrls: resolveCustomHttpsEndpointUrls(), }); + const tailscaleEndpoints = await resolveTailscaleAdvertisedEndpoints({ + port: backendPort, + networkInterfaces: OS.networkInterfaces(), + }); + return [...coreEndpoints, ...tailscaleEndpoints]; } function getDesktopSecretStorage() { @@ -399,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) { @@ -655,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"; @@ -1673,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()); @@ -2048,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; } @@ -2139,6 +2162,7 @@ app.on("before-quit", () => { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); }); @@ -2188,6 +2212,7 @@ if (process.platform !== "win32") { clearUpdatePollTimer(); cancelBackendReadinessWait(); stopBackend(); + void desktopSshEnvironmentBridge.dispose().catch(() => undefined); restoreStdIoCapture?.(); app.quit(); }); @@ -2198,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 e918e782ab9..fdfaf20813f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -22,6 +22,14 @@ const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-re const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret"; const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret"; const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; +const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; +const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; +const FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL = "desktop:fetch-ssh-environment-descriptor"; +const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-bearer-session"; +const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; +const ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL = "desktop:issue-ssh-websocket-token"; +const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; +const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; @@ -52,6 +60,30 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret), removeSavedEnvironmentSecret: (environmentId) => ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), + discoverSshHosts: () => ipcRenderer.invoke(DISCOVER_SSH_HOSTS_CHANNEL), + ensureSshEnvironment: (target, options) => + ipcRenderer.invoke(ENSURE_SSH_ENVIRONMENT_CHANNEL, target, options), + fetchSshEnvironmentDescriptor: (httpBaseUrl) => + ipcRenderer.invoke(FETCH_SSH_ENVIRONMENT_DESCRIPTOR_CHANNEL, httpBaseUrl), + bootstrapSshBearerSession: (httpBaseUrl, credential) => + ipcRenderer.invoke(BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL, httpBaseUrl, credential), + fetchSshSessionState: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(FETCH_SSH_SESSION_STATE_CHANNEL, httpBaseUrl, bearerToken), + issueSshWebSocketToken: (httpBaseUrl, bearerToken) => + ipcRenderer.invoke(ISSUE_SSH_WEBSOCKET_TOKEN_CHANNEL, httpBaseUrl, bearerToken), + onSshPasswordPrompt: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + + ipcRenderer.on(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); + }; + }, + resolveSshPasswordPrompt: (requestId, password) => + ipcRenderer.invoke(RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, requestId, password), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), getAdvertisedEndpoints: () => ipcRenderer.invoke(GET_ADVERTISED_ENDPOINTS_CHANNEL), diff --git a/apps/desktop/src/sshEnvironment.test.ts b/apps/desktop/src/sshEnvironment.test.ts new file mode 100644 index 00000000000..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 31253781930..95e504207d9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,9 +1,11 @@ -import { ChevronDownIcon, PlusIcon, QrCodeIcon } from "lucide-react"; +import { ChevronDownIcon, PlusIcon, QrCodeIcon, RefreshCwIcon } from "lucide-react"; import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { type AuthClientSession, type AuthPairingLink, type AdvertisedEndpoint, + type DesktopDiscoveredSshHost, + type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type EnvironmentId, } from "@t3tools/contracts"; @@ -30,6 +32,7 @@ import { DialogTitle, DialogTrigger, } from "../ui/dialog"; +import { ScrollArea } from "../ui/scroll-area"; import { AlertDialog, AlertDialogClose, @@ -67,6 +70,7 @@ import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, addSavedEnvironment, + connectDesktopSshEnvironment, getPrimaryEnvironmentConnection, reconnectSavedEnvironment, removeSavedEnvironment, @@ -165,6 +169,69 @@ function getSavedBackendStatusTooltip( : "Not connected yet."; } +function formatDesktopSshTarget(target: NonNullable): string { + const authority = target.username ? `${target.username}@${target.hostname}` : target.hostname; + return target.port ? `${authority}:${target.port}` : authority; +} + +function parseManualDesktopSshTarget(input: { + readonly host: string; + readonly username: string; + readonly port: string; +}): DesktopSshEnvironmentTarget { + const rawHost = input.host.trim(); + if (rawHost.length === 0) { + throw new Error("SSH host or alias is required."); + } + + let hostname = rawHost; + let username = input.username.trim() || null; + let port: number | null = null; + + const atIndex = hostname.lastIndexOf("@"); + if (atIndex > 0) { + const inlineUsername = hostname.slice(0, atIndex).trim(); + hostname = hostname.slice(atIndex + 1).trim(); + if (!username && inlineUsername.length > 0) { + username = inlineUsername; + } + } + + const bracketedHostMatch = /^\[([^\]]+)\](?::(\d+))?$/u.exec(hostname); + if (bracketedHostMatch) { + hostname = bracketedHostMatch[1]!.trim(); + if (bracketedHostMatch[2]) { + port = Number.parseInt(bracketedHostMatch[2], 10); + } + } else { + const colonSegments = hostname.split(":"); + if (colonSegments.length === 2 && /^\d+$/u.test(colonSegments[1] ?? "")) { + hostname = colonSegments[0]!.trim(); + port = Number.parseInt(colonSegments[1]!, 10); + } + } + + const rawPort = input.port.trim(); + if (rawPort.length > 0) { + port = Number.parseInt(rawPort, 10); + } + + if (hostname.length === 0) { + throw new Error("SSH host or alias is required."); + } + + if (port !== null && (!Number.isInteger(port) || port <= 0 || port > 65_535)) { + throw new Error("SSH port must be between 1 and 65535."); + } + + return { + alias: hostname, + hostname, + username, + port, + }; +} + /** Direct row in the card – same pattern as the Provider / ACP-agent list rows. */ const ITEM_ROW_CLASSNAME = "border-t border-border/60 px-4 py-4 first:border-t-0 sm:px-5"; const ENDPOINT_ROW_CLASSNAME = "border-t border-border/60 px-4 py-2.5 first:border-t-0 sm:px-5"; @@ -933,6 +1000,7 @@ function SavedBackendListRow({ const descriptorLabel = runtime?.descriptor?.label ?? null; const statusTooltip = getSavedBackendStatusTooltip(runtime, record, nowMs); const metadataBits = [ + record.desktopSsh ? `SSH ${formatDesktopSshTarget(record.desktopSsh)}` : null, roleLabel, record.lastConnectedAt ? `Last connected ${formatAccessTimestamp(record.lastConnectedAt)}` @@ -983,6 +1051,59 @@ function SavedBackendListRow({ ); } +interface DesktopSshHostRowProps { + target: DesktopDiscoveredSshHost; + savedRecord: SavedEnvironmentRecord | null; + connectingHostAlias: string | null; + onConnect: (target: DesktopDiscoveredSshHost) => void; +} + +const DesktopSshHostRow = memo(function DesktopSshHostRow({ + target, + savedRecord, + connectingHostAlias, + onConnect, +}: DesktopSshHostRowProps) { + const address = formatDesktopSshTarget(target); + const secondaryBits = [target.source === "ssh-config" ? "SSH config" : "Known hosts", address]; + const buttonLabel = + connectingHostAlias === target.alias ? "Connecting…" : savedRecord ? "Reconnect" : "Connect"; + + return ( +
+
+
+
+

{target.alias}

+
+

{secondaryBits.join(" · ")}

+ {savedRecord ? ( +

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

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

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

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

Enter the backend host and pairing code separately.

+ ) : ( +

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

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

Discovered hosts

+

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

+
+ +
+ {discoveredSshHostsError ? ( +

{discoveredSshHostsError}

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

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

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

{savedBackendError}

+ ) : null} +
+

+ Enter a host manually +

+

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

+
+ + +
+ + +
+ + Uses your existing SSH keys, agent, and config. Password and + keyboard-interactive prompts open through your system SSH dialog when + needed. - setSavedBackendPairingCode(event.target.value)} - placeholder="Pairing code" +
- {savedBackendError ? ( + onClick={() => void handleAddSavedBackend()} + > + + {isAddingSavedBackend ? "Connecting…" : "Connect SSH host"} + +
+
+
+ )} + {savedBackendMode !== "ssh" && savedBackendError ? (

{savedBackendError}

) : null} - + {savedBackendMode !== "ssh" ? ( + + ) : null}
diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 92c59178894..cb3cb0ee874 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -114,6 +114,8 @@ const authAccessHarness = vi.hoisted(() => { }; }); +const mockConnectDesktopSshEnvironment = vi.hoisted(() => vi.fn()); + vi.mock("../../environments/runtime", () => { const primaryConnection = { kind: "primary" as const, @@ -151,6 +153,7 @@ vi.mock("../../environments/runtime", () => { new URL(path, "http://localhost:3000").toString(), waitForSavedEnvironmentRegistryHydration: async () => undefined, addSavedEnvironment: vi.fn(), + connectDesktopSshEnvironment: mockConnectDesktopSshEnvironment, disconnectSavedEnvironment: vi.fn(), ensureEnvironmentConnectionBootstrapped: async () => undefined, getPrimaryEnvironmentConnection: () => primaryConnection, @@ -258,6 +261,7 @@ function makeClientSession(input: { } const createDesktopBridgeStub = (overrides?: { + readonly discoverSshHosts?: DesktopBridge["discoverSshHosts"]; readonly serverExposureState?: Awaited>; readonly advertisedEndpoints?: Awaited>; readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; @@ -295,6 +299,50 @@ const createDesktopBridgeStub = (overrides?: { getSavedEnvironmentSecret: vi.fn().mockResolvedValue(null), setSavedEnvironmentSecret: vi.fn().mockResolvedValue(true), removeSavedEnvironmentSecret: vi.fn().mockResolvedValue(undefined), + discoverSshHosts: overrides?.discoverSshHosts ?? vi.fn().mockResolvedValue([]), + ensureSshEnvironment: vi.fn().mockImplementation(async (target) => ({ + target, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + pairingToken: "ssh-pairing-token", + })), + fetchSshEnvironmentDescriptor: vi.fn().mockResolvedValue({ + environmentId: "environment-ssh", + label: "SSH environment", + platform: { + os: "linux", + arch: "x64", + }, + serverVersion: "0.0.0-test", + capabilities: { + repositoryIdentity: true, + }, + }), + bootstrapSshBearerSession: vi.fn().mockResolvedValue({ + authenticated: true, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-05-01T12:00:00.000Z", + sessionToken: "ssh-bearer-token", + }), + fetchSshSessionState: vi.fn().mockResolvedValue({ + authenticated: true, + auth: { + policy: "remote-reachable", + bootstrapMethods: ["one-time-token"], + sessionMethods: ["browser-session-cookie", "bearer-session-token"], + sessionCookieName: "t3_session", + }, + role: "owner", + sessionMethod: "bearer-session-token", + expiresAt: "2026-05-01T12:00:00.000Z", + }), + issueSshWebSocketToken: vi.fn().mockResolvedValue({ + token: "ssh-ws-token", + expiresAt: "2026-05-01T12:05:00.000Z", + }), + onSshPasswordPrompt: vi.fn(() => () => {}), + resolveSshPasswordPrompt: vi.fn().mockResolvedValue(undefined), getServerExposureState: vi.fn().mockResolvedValue( overrides?.serverExposureState ?? { mode: "local-only", @@ -348,6 +396,7 @@ describe("GeneralSettingsPanel observability", () => { localStorage.clear(); useUiStateStore.setState({ defaultAdvertisedEndpointKey: null }); authAccessHarness.reset(); + mockConnectDesktopSshEnvironment.mockReset(); }); afterEach(async () => { @@ -852,6 +901,74 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("adds desktop ssh environments from the add-environment dialog", async () => { + const discoverSshHosts = vi.fn().mockResolvedValue([ + { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + source: "ssh-config" as const, + }, + ]); + window.desktopBridge = createDesktopBridgeStub({ + discoverSshHosts, + }); + mockConnectDesktopSshEnvironment.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-devbox"), + label: "Build box", + wsBaseUrl: "ws://127.0.0.1:3774/", + httpBaseUrl: "http://127.0.0.1:3774/", + createdAt: "2036-04-07T00:00:00.000Z", + lastConnectedAt: "2036-04-07T00:00:00.000Z", + desktopSsh: { + alias: "devbox.example.com", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + }); + + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + await page.getByRole("button", { name: "Add environment", exact: true }).click(); + await expect + .element(page.getByRole("heading", { name: "Add Environment", exact: true })) + .toBeInTheDocument(); + await page.getByRole("button", { name: "SSH", exact: true }).click(); + await vi.waitFor(() => { + expect(discoverSshHosts).toHaveBeenCalledTimes(1); + }); + await expect + .element(page.getByRole("heading", { name: "devbox", exact: true })) + .toBeInTheDocument(); + + await page.getByText("Enter a host manually").click(); + await page.getByLabelText("Label").fill("Build box"); + await page.getByLabelText("SSH host or alias").fill("devbox.example.com"); + await page.getByLabelText("Username").fill("julius"); + await page.getByLabelText("Port").fill("2222"); + await page.getByRole("button", { name: "Connect SSH host", exact: true }).click(); + + await vi.waitFor(() => { + expect(mockConnectDesktopSshEnvironment).toHaveBeenCalledWith( + { + alias: "devbox.example.com", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + }, + { label: "Build box" }, + ); + }); + }); + it("opens the logs folder in the preferred editor", async () => { const openInEditor = vi.fn().mockResolvedValue(undefined); window.nativeApi = { diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 080ac809975..bebe1b0ee03 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -24,7 +24,7 @@ function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props) return ( | null = null; -function toPersistedSavedEnvironmentRecord( +export function toPersistedSavedEnvironmentRecord( record: SavedEnvironmentRecord, ): PersistedSavedEnvironmentRecord { return { @@ -44,6 +45,7 @@ function toPersistedSavedEnvironmentRecord( wsBaseUrl: record.wsBaseUrl, createdAt: record.createdAt, lastConnectedAt: record.lastConnectedAt, + ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), }; } diff --git a/apps/web/src/environments/runtime/index.ts b/apps/web/src/environments/runtime/index.ts index 8c813f10826..0192fb6026d 100644 --- a/apps/web/src/environments/runtime/index.ts +++ b/apps/web/src/environments/runtime/index.ts @@ -16,6 +16,7 @@ export { export { addSavedEnvironment, + connectDesktopSshEnvironment, disconnectSavedEnvironment, ensureEnvironmentConnectionBootstrapped, getPrimaryEnvironmentConnection, diff --git a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts index 9bcbc4f7133..a6ee6c5d222 100644 --- a/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +++ b/apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts @@ -1,14 +1,54 @@ import { EnvironmentId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; +let mockSavedRecords: Array> = []; + const mockResolveRemotePairingTarget = vi.fn(); const mockFetchRemoteEnvironmentDescriptor = vi.fn(); const mockBootstrapRemoteBearerSession = vi.fn(); +const mockFetchRemoteSessionState = vi.fn(); +const mockIsRemoteEnvironmentAuthHttpError = vi.fn((_: unknown) => false); +const mockResolveRemoteWebSocketConnectionUrl = vi.fn(); +const mockBootstrapSshBearerSession = vi.fn(); +const mockFetchSshSessionState = vi.fn(); const mockPersistSavedEnvironmentRecord = vi.fn(); const mockWriteSavedEnvironmentBearerToken = vi.fn(); const mockSetSavedEnvironmentRegistry = vi.fn(); -const mockUpsert = vi.fn(); -const mockListSavedEnvironmentRecords = vi.fn(); +const mockGetSavedEnvironmentRecord = vi.fn((environmentId: EnvironmentId) => { + return mockSavedRecords.find((record) => record.environmentId === environmentId) ?? null; +}); +const mockReadSavedEnvironmentBearerToken = vi.fn(); +const mockRemoveSavedEnvironmentBearerToken = vi.fn(); +const mockPatchRuntime = vi.fn(); +const mockClearRuntime = vi.fn(); +const mockRegistrySetState = vi.fn((next: { byId: Record> }) => { + mockSavedRecords = Object.values(next.byId); +}); +const mockRemove = vi.fn((environmentId: EnvironmentId) => { + mockSavedRecords = mockSavedRecords.filter((record) => record.environmentId !== environmentId); +}); +const mockMarkConnected = vi.fn((environmentId: EnvironmentId, connectedAt: string) => { + mockSavedRecords = mockSavedRecords.map((record) => + record.environmentId === environmentId ? { ...record, lastConnectedAt: connectedAt } : record, + ); +}); +const mockUpsert = vi.fn((record: Record) => { + mockSavedRecords = [ + ...mockSavedRecords.filter((entry) => entry.environmentId !== record.environmentId), + record, + ]; +}); +const mockListSavedEnvironmentRecords = vi.fn(() => mockSavedRecords); +const mockEnsureSshEnvironment = vi.fn(); +const mockFetchSshEnvironmentDescriptor = vi.fn(); +const mockToPersistedSavedEnvironmentRecord = vi.fn((record) => record); +const mockCreateEnvironmentConnection = vi.fn(); +const mockClientGetConfig = vi.fn(async () => ({ + environment: { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }, +})); vi.mock("../remote/target", () => ({ resolveRemotePairingTarget: mockResolveRemotePairingTarget, @@ -17,8 +57,9 @@ vi.mock("../remote/target", () => ({ vi.mock("../remote/api", () => ({ bootstrapRemoteBearerSession: mockBootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor: mockFetchRemoteEnvironmentDescriptor, - fetchRemoteSessionState: vi.fn(), - resolveRemoteWebSocketConnectionUrl: vi.fn(), + fetchRemoteSessionState: mockFetchRemoteSessionState, + isRemoteEnvironmentAuthHttpError: mockIsRemoteEnvironmentAuthHttpError, + resolveRemoteWebSocketConnectionUrl: mockResolveRemoteWebSocketConnectionUrl, })); vi.mock("~/localApi", () => ({ @@ -30,24 +71,27 @@ vi.mock("~/localApi", () => ({ })); vi.mock("./catalog", () => ({ - getSavedEnvironmentRecord: vi.fn(), + getSavedEnvironmentRecord: mockGetSavedEnvironmentRecord, hasSavedEnvironmentRegistryHydrated: vi.fn(), listSavedEnvironmentRecords: mockListSavedEnvironmentRecords, persistSavedEnvironmentRecord: mockPersistSavedEnvironmentRecord, - readSavedEnvironmentBearerToken: vi.fn(), - removeSavedEnvironmentBearerToken: vi.fn(), + readSavedEnvironmentBearerToken: mockReadSavedEnvironmentBearerToken, + removeSavedEnvironmentBearerToken: mockRemoveSavedEnvironmentBearerToken, + toPersistedSavedEnvironmentRecord: mockToPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore: { getState: () => ({ upsert: mockUpsert, - remove: vi.fn(), - markConnected: vi.fn(), + remove: mockRemove, + markConnected: mockMarkConnected, }), + setState: mockRegistrySetState, + subscribe: vi.fn(() => () => {}), }, useSavedEnvironmentRuntimeStore: { getState: () => ({ ensure: vi.fn(), - patch: vi.fn(), - clear: vi.fn(), + patch: mockPatchRuntime, + clear: mockClearRuntime, }), }, waitForSavedEnvironmentRegistryHydration: vi.fn(), @@ -55,18 +99,53 @@ vi.mock("./catalog", () => ({ })); vi.mock("./connection", () => ({ - createEnvironmentConnection: vi.fn(), + createEnvironmentConnection: mockCreateEnvironmentConnection, +})); + +vi.mock("../../rpc/wsRpcClient", () => ({ + createWsRpcClient: vi.fn(() => ({ + server: { + getConfig: mockClientGetConfig, + }, + orchestration: { + subscribeThread: vi.fn(() => () => {}), + }, + })), +})); + +vi.mock("../../rpc/wsTransport", () => ({ + WsTransport: vi.fn(), })); describe("addSavedEnvironment", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); - mockResolveRemotePairingTarget.mockReturnValue({ - httpBaseUrl: "https://remote.example.com/", - wsBaseUrl: "wss://remote.example.com/", - credential: "pairing-code", + mockSavedRecords = []; + vi.stubGlobal("window", { + desktopBridge: { + ensureSshEnvironment: mockEnsureSshEnvironment, + fetchSshEnvironmentDescriptor: mockFetchSshEnvironmentDescriptor, + bootstrapSshBearerSession: mockBootstrapSshBearerSession, + fetchSshSessionState: mockFetchSshSessionState, + issueSshWebSocketToken: vi.fn(), + }, }); + mockResolveRemotePairingTarget.mockImplementation( + (input: { host?: string; pairingCode?: string }) => ({ + httpBaseUrl: input.host + ? input.host.endsWith("/") + ? input.host + : `${input.host}/` + : "https://remote.example.com/", + wsBaseUrl: input.host + ? input.host.replace(/^http/u, "ws").endsWith("/") + ? input.host.replace(/^http/u, "ws") + : `${input.host.replace(/^http/u, "ws")}/` + : "wss://remote.example.com/", + credential: input.pairingCode ?? "pairing-code", + }), + ); mockFetchRemoteEnvironmentDescriptor.mockResolvedValue({ environmentId: EnvironmentId.make("environment-1"), label: "Remote environment", @@ -75,10 +154,59 @@ describe("addSavedEnvironment", () => { sessionToken: "bearer-token", role: "owner", }); + mockFetchRemoteSessionState.mockResolvedValue({ + authenticated: true, + role: "owner", + }); + mockIsRemoteEnvironmentAuthHttpError.mockReturnValue(false); + mockResolveRemoteWebSocketConnectionUrl.mockResolvedValue( + "wss://remote.example.com/?wsToken=remote-token", + ); + mockFetchSshEnvironmentDescriptor.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }); + mockBootstrapSshBearerSession.mockResolvedValue({ + sessionToken: "ssh-bearer-token", + role: "owner", + }); mockPersistSavedEnvironmentRecord.mockResolvedValue(undefined); mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); mockSetSavedEnvironmentRegistry.mockResolvedValue(undefined); - mockListSavedEnvironmentRecords.mockReturnValue([]); + mockReadSavedEnvironmentBearerToken.mockResolvedValue(null); + mockRemoveSavedEnvironmentBearerToken.mockResolvedValue(undefined); + mockFetchSshSessionState.mockResolvedValue({ + authenticated: true, + role: "owner", + }); + mockCreateEnvironmentConnection.mockImplementation( + (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => ({ + kind: "saved", + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: async () => undefined, + }), + ); + mockClientGetConfig.mockResolvedValue({ + environment: { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + }, + }); + mockEnsureSshEnvironment.mockResolvedValue({ + target: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + pairingToken: "ssh-pairing-code", + }); }); it("rolls back persisted metadata when bearer token persistence fails", async () => { @@ -102,4 +230,327 @@ describe("addSavedEnvironment", () => { await resetEnvironmentServiceForTests(); }); + + it("restores unrelated saved environments when credential persistence rollback runs", async () => { + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-existing"), + label: "Existing environment", + httpBaseUrl: "https://existing.example.com/", + wsBaseUrl: "wss://existing.example.com/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + }, + ]; + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("Unable to persist saved environment credentials."); + + expect(mockSetSavedEnvironmentRegistry).toHaveBeenCalledWith([ + expect.objectContaining({ + environmentId: EnvironmentId.make("environment-existing"), + }), + ]); + + await resetEnvironmentServiceForTests(); + }); + + it("removes an older ssh record when the same target returns a new environment id", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockFetchSshEnvironmentDescriptor.mockResolvedValue({ + environmentId: EnvironmentId.make("environment-2"), + label: "Remote environment", + }); + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Old ssh environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }), + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-2"), + }); + + expect(mockUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: EnvironmentId.make("environment-2"), + }), + ); + expect(mockRemove).toHaveBeenCalledWith(EnvironmentId.make("environment-1")); + expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + + await resetEnvironmentServiceForTests(); + }); + + it("retries desktop ssh session refresh when the forwarded endpoint returns ssh_http 401", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockBootstrapSshBearerSession + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token", + role: "owner", + }) + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token-2", + role: "owner", + }); + mockFetchSshSessionState + .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) + .mockResolvedValueOnce({ + authenticated: true, + role: "owner", + }); + + const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await expect( + connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-1"), + }); + + expect(mockEnsureSshEnvironment).toHaveBeenCalled(); + expect(mockBootstrapSshBearerSession).toHaveBeenCalledTimes(2); + expect(mockFetchSshSessionState).toHaveBeenCalledTimes(2); + + await resetEnvironmentServiceForTests(); + }); + + it("does not attempt desktop ssh bearer recovery for non-ssh saved environments", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + const authError = { + status: 401, + message: "Unauthorized", + }; + mockFetchRemoteSessionState.mockRejectedValueOnce(authError); + mockIsRemoteEnvironmentAuthHttpError.mockImplementation( + (error: unknown) => error === authError, + ); + + const { addSavedEnvironment, resetEnvironmentServiceForTests } = await import("./service"); + + await expect( + addSavedEnvironment({ + label: "Remote environment", + host: "remote.example.com", + pairingCode: "123456", + }), + ).rejects.toThrow("Saved environment credential expired. Pair it again."); + + expect(mockEnsureSshEnvironment).not.toHaveBeenCalled(); + expect(mockBootstrapSshBearerSession).not.toHaveBeenCalled(); + expect(mockRemoveSavedEnvironmentBearerToken).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + ); + + await resetEnvironmentServiceForTests(); + }); + + it("only registers the retried ssh connection after bearer re-issuance succeeds", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + mockBootstrapSshBearerSession + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token", + role: "owner", + }) + .mockResolvedValueOnce({ + sessionToken: "ssh-bearer-token-2", + role: "owner", + }); + mockFetchSshSessionState + .mockRejectedValueOnce(new Error("[ssh_http:401] Unauthorized")) + .mockResolvedValueOnce({ + authenticated: true, + role: "owner", + }); + + const createdConnections: Array<{ + readonly environmentId: EnvironmentId; + readonly dispose: ReturnType; + }> = []; + mockCreateEnvironmentConnection.mockImplementation( + (input: { knownEnvironment: { environmentId: EnvironmentId }; client: unknown }) => { + const connection = { + kind: "saved" as const, + environmentId: input.knownEnvironment.environmentId, + knownEnvironment: input.knownEnvironment, + client: input.client, + ensureBootstrapped: async () => undefined, + reconnect: async () => undefined, + dispose: vi.fn(async () => undefined), + }; + createdConnections.push(connection); + return connection; + }, + ); + + const { + connectDesktopSshEnvironment, + listEnvironmentConnections, + resetEnvironmentServiceForTests, + } = await import("./service"); + + await connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }); + + expect(createdConnections).toHaveLength(2); + expect(createdConnections[0]?.dispose).toHaveBeenCalledTimes(1); + expect(listEnvironmentConnections()).toHaveLength(1); + expect(listEnvironmentConnections()[0]).toBe(createdConnections[1]); + + await resetEnvironmentServiceForTests(); + }); + + it("marks desktop ssh reconnect failures as runtime errors when bearer recovery fails", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + + const connection = { + kind: "saved" as const, + environmentId: EnvironmentId.make("environment-1"), + knownEnvironment: { + environmentId: EnvironmentId.make("environment-1"), + }, + client: {}, + ensureBootstrapped: async () => undefined, + reconnect: vi.fn(async () => { + throw new Error("socket closed"); + }), + dispose: async () => undefined, + }; + mockCreateEnvironmentConnection.mockReturnValue(connection); + + const { addSavedEnvironment, reconnectSavedEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await addSavedEnvironment({ + label: "Remote environment", + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }); + + mockSavedRecords = [ + { + environmentId: EnvironmentId.make("environment-1"), + label: "Remote environment", + httpBaseUrl: "http://127.0.0.1:3774/", + wsBaseUrl: "ws://127.0.0.1:3774/", + createdAt: "2026-04-14T00:00:00.000Z", + lastConnectedAt: null, + desktopSsh: { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 22, + }, + }, + ]; + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(false); + + await expect(reconnectSavedEnvironment(EnvironmentId.make("environment-1"))).rejects.toThrow( + "Unable to persist saved environment credentials.", + ); + + expect(mockPatchRuntime).toHaveBeenCalledWith( + EnvironmentId.make("environment-1"), + expect.objectContaining({ + connectionState: "error", + lastError: "Unable to persist saved environment credentials.", + }), + ); + + await resetEnvironmentServiceForTests(); + }); + + it("bootstraps a desktop ssh environment through the desktop bridge", async () => { + mockWriteSavedEnvironmentBearerToken.mockResolvedValue(true); + + const { connectDesktopSshEnvironment, resetEnvironmentServiceForTests } = + await import("./service"); + + await expect( + connectDesktopSshEnvironment({ + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }), + ).resolves.toMatchObject({ + environmentId: EnvironmentId.make("environment-1"), + }); + + expect(mockEnsureSshEnvironment).toHaveBeenCalledWith( + { + alias: "devbox", + hostname: "devbox", + username: null, + port: null, + }, + { issuePairingToken: true }, + ); + expect(mockResolveRemotePairingTarget).toHaveBeenCalledWith({ + host: "http://127.0.0.1:3774/", + pairingCode: "ssh-pairing-code", + }); + expect(mockFetchSshEnvironmentDescriptor).toHaveBeenCalledWith("http://127.0.0.1:3774/"); + expect(mockBootstrapSshBearerSession).toHaveBeenCalledWith( + "http://127.0.0.1:3774/", + "ssh-pairing-code", + ); + expect(mockFetchRemoteEnvironmentDescriptor).not.toHaveBeenCalled(); + expect(mockBootstrapRemoteBearerSession).not.toHaveBeenCalled(); + expect(mockUpsert.mock.invocationCallOrder[0]).toBeLessThan( + mockCreateEnvironmentConnection.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + + await resetEnvironmentServiceForTests(); + }); }); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 6c58f9e8a53..1ff8b662afd 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -1,5 +1,7 @@ import { type AuthSessionRole, + type DesktopSshEnvironmentBootstrap, + type DesktopSshEnvironmentTarget, type EnvironmentId, type OrchestrationEvent, type OrchestrationShellSnapshot, @@ -33,6 +35,7 @@ import { bootstrapRemoteBearerSession, fetchRemoteEnvironmentDescriptor, fetchRemoteSessionState, + isRemoteEnvironmentAuthHttpError, resolveRemoteWebSocketConnectionUrl, } from "../remote/api"; import { resolveRemotePairingTarget } from "../remote/target"; @@ -44,6 +47,7 @@ import { readSavedEnvironmentBearerToken, removeSavedEnvironmentBearerToken, type SavedEnvironmentRecord, + toPersistedSavedEnvironmentRecord, useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, waitForSavedEnvironmentRegistryHydration, @@ -85,6 +89,7 @@ type ThreadDetailSubscriptionEntry = { }; const environmentConnections = new Map(); +const pendingSavedEnvironmentConnections = new Map>(); const environmentConnectionListeners = new Set<() => void>(); const threadDetailSubscriptions = new Map(); const lastAppliedProjectionVersionByEnvironment = new Map< @@ -108,6 +113,7 @@ let needsProviderInvalidation = false; const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; const NOOP = () => undefined; +const SSH_HTTP_STATUS_RE = /^\[ssh_http:(\d+)\]\s/u; function compareAppliedProjectionVersion( left: { readonly sequence: number; readonly updatedAt: string | null }, @@ -491,6 +497,206 @@ function isoNow(): string { return new Date().toISOString(); } +function readSshHttpErrorStatus(error: unknown): number | null { + if (!(error instanceof Error)) { + return null; + } + + const match = SSH_HTTP_STATUS_RE.exec(error.message); + if (!match) { + return null; + } + + const parsed = Number.parseInt(match[1] ?? "", 10); + return Number.isInteger(parsed) ? parsed : null; +} + +function isSshHttpAuthError(error: unknown, status: number): boolean { + return readSshHttpErrorStatus(error) === status; +} + +function isDesktopSshTargetEqual( + left: DesktopSshEnvironmentTarget | undefined, + right: DesktopSshEnvironmentTarget | undefined, +): boolean { + if (!left || !right) { + return false; + } + + return ( + left.alias === right.alias && + left.hostname === right.hostname && + left.username === right.username && + left.port === right.port + ); +} + +function findSavedEnvironmentRecordByDesktopSshTarget( + target: DesktopSshEnvironmentTarget | undefined, +): SavedEnvironmentRecord | null { + if (!target) { + return null; + } + + return ( + listSavedEnvironmentRecords().find((record) => + isDesktopSshTargetEqual(record.desktopSsh, target), + ) ?? null + ); +} + +function buildSavedEnvironmentRegistryById( + records: ReadonlyArray, +): Record { + return Object.fromEntries(records.map((record) => [record.environmentId, record])) as Record< + EnvironmentId, + SavedEnvironmentRecord + >; +} + +type SavedEnvironmentRegistrySnapshot = ReadonlyMap; + +function snapshotSavedEnvironmentRegistry( + environmentIds: ReadonlyArray, +): SavedEnvironmentRegistrySnapshot { + return new Map( + environmentIds.map((environmentId) => [ + environmentId, + getSavedEnvironmentRecord(environmentId) ?? null, + ]), + ); +} + +async function persistSavedEnvironmentRegistryRollback( + snapshot: SavedEnvironmentRegistrySnapshot, +): Promise { + const byId = buildSavedEnvironmentRegistryById(listSavedEnvironmentRecords()); + for (const [environmentId, record] of snapshot) { + if (record) { + byId[environmentId] = record; + continue; + } + delete byId[environmentId]; + } + const records = Object.values(byId); + await ensureLocalApi().persistence.setSavedEnvironmentRegistry( + records.map((entry) => toPersistedSavedEnvironmentRecord(entry)), + ); + useSavedEnvironmentRegistryStore.setState({ + byId, + }); +} + +async function resolveDesktopSshEnvironmentBootstrap( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, +): Promise { + const desktopBridge = window.desktopBridge; + if (!desktopBridge) { + throw new Error("SSH launch is only available in the desktop app."); + } + + return await desktopBridge.ensureSshEnvironment(target, options); +} + +function getDesktopSshBridge() { + const desktopBridge = window.desktopBridge; + if (!desktopBridge) { + throw new Error("SSH launch is only available in the desktop app."); + } + return desktopBridge; +} + +async function fetchDesktopSshEnvironmentDescriptor(httpBaseUrl: string) { + return await getDesktopSshBridge().fetchSshEnvironmentDescriptor(httpBaseUrl); +} + +async function bootstrapDesktopSshBearerSession(httpBaseUrl: string, credential: string) { + return await getDesktopSshBridge().bootstrapSshBearerSession(httpBaseUrl, credential); +} + +async function fetchDesktopSshSessionState(httpBaseUrl: string, bearerToken: string) { + return await getDesktopSshBridge().fetchSshSessionState(httpBaseUrl, bearerToken); +} + +async function resolveDesktopSshWebSocketConnectionUrl( + wsBaseUrl: string, + httpBaseUrl: string, + bearerToken: string, +) { + const issued = await getDesktopSshBridge().issueSshWebSocketToken(httpBaseUrl, bearerToken); + const url = new URL(wsBaseUrl, window.location.origin); + url.searchParams.set("wsToken", issued.token); + return url.toString(); +} + +async function prepareSavedEnvironmentRecordForConnection( + record: SavedEnvironmentRecord, + options?: { readonly issuePairingToken?: boolean }, +): Promise<{ + readonly record: SavedEnvironmentRecord; + readonly pairingToken: string | null; +}> { + if (!record.desktopSsh) { + return { record, pairingToken: null }; + } + + const bootstrap = await resolveDesktopSshEnvironmentBootstrap(record.desktopSsh, options); + const nextRecord: SavedEnvironmentRecord = { + ...record, + httpBaseUrl: bootstrap.httpBaseUrl, + wsBaseUrl: bootstrap.wsBaseUrl, + desktopSsh: bootstrap.target, + }; + + if ( + nextRecord.httpBaseUrl !== record.httpBaseUrl || + nextRecord.wsBaseUrl !== record.wsBaseUrl || + !isDesktopSshTargetEqual(nextRecord.desktopSsh, record.desktopSsh) + ) { + await persistSavedEnvironmentRecord(nextRecord); + useSavedEnvironmentRegistryStore.getState().upsert(nextRecord); + } + + return { + record: nextRecord, + pairingToken: bootstrap.pairingToken, + }; +} + +async function issueDesktopSshBearerSession(record: SavedEnvironmentRecord): Promise<{ + readonly record: SavedEnvironmentRecord; + readonly bearerToken: string; + readonly role: AuthSessionRole | null; +}> { + const registrySnapshot = snapshotSavedEnvironmentRegistry([record.environmentId]); + const prepared = await prepareSavedEnvironmentRecordForConnection(record, { + issuePairingToken: true, + }); + if (!prepared.pairingToken) { + throw new Error("Desktop SSH launch did not return a pairing token."); + } + + const bearerSession = await bootstrapDesktopSshBearerSession( + prepared.record.httpBaseUrl, + prepared.pairingToken, + ); + const didPersistBearerToken = await writeSavedEnvironmentBearerToken( + prepared.record.environmentId, + bearerSession.sessionToken, + ); + if (!didPersistBearerToken) { + await persistSavedEnvironmentRegistryRollback(registrySnapshot); + throw new Error("Unable to persist saved environment credentials."); + } + + return { + record: prepared.record, + bearerToken: bearerSession.sessionToken, + role: bearerSession.role ?? null, + }; +} + function setRuntimeConnecting(environmentId: EnvironmentId) { useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { connectionState: "connecting", @@ -802,35 +1008,46 @@ function createPrimaryEnvironmentClient( } function createSavedEnvironmentClient( - record: SavedEnvironmentRecord, + environmentId: EnvironmentId, bearerToken: string, ): WsRpcClient { - useSavedEnvironmentRuntimeStore.getState().ensure(record.environmentId); + useSavedEnvironmentRuntimeStore.getState().ensure(environmentId); return createWsRpcClient( new WsTransport( - () => - resolveRemoteWebSocketConnectionUrl({ - wsBaseUrl: record.wsBaseUrl, - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), + async () => { + const record = getSavedEnvironmentRecord(environmentId); + if (!record) { + throw new Error(`Saved environment ${environmentId} not found.`); + } + return record.desktopSsh + ? await resolveDesktopSshWebSocketConnectionUrl( + record.wsBaseUrl, + record.httpBaseUrl, + bearerToken, + ) + : await resolveRemoteWebSocketConnectionUrl({ + wsBaseUrl: record.wsBaseUrl, + httpBaseUrl: record.httpBaseUrl, + bearerToken, + }); + }, { onAttempt: () => { - setRuntimeConnecting(record.environmentId); + setRuntimeConnecting(environmentId); }, onOpen: () => { - setRuntimeConnected(record.environmentId); + setRuntimeConnected(environmentId); }, onError: (message: string) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { + useSavedEnvironmentRuntimeStore.getState().patch(environmentId, { connectionState: "error", lastError: message, lastErrorAt: isoNow(), }); }, onClose: (details: { readonly code: number; readonly reason: string }) => { - setRuntimeDisconnected(record.environmentId, details.reason); + setRuntimeDisconnected(environmentId, details.reason); }, }, ), @@ -838,18 +1055,25 @@ function createSavedEnvironmentClient( } async function refreshSavedEnvironmentMetadata( - record: SavedEnvironmentRecord, + environmentId: EnvironmentId, bearerToken: string, client: WsRpcClient, roleHint?: AuthSessionRole | null, configHint?: ServerConfig | null, ): Promise { + const record = getSavedEnvironmentRecord(environmentId); + if (!record) { + throw new Error(`Saved environment ${environmentId} not found.`); + } + const [serverConfig, sessionState] = await Promise.all([ configHint ? Promise.resolve(configHint) : client.server.getConfig(), - fetchRemoteSessionState({ - httpBaseUrl: record.httpBaseUrl, - bearerToken, - }), + record.desktopSsh + ? fetchDesktopSshSessionState(record.httpBaseUrl, bearerToken) + : fetchRemoteSessionState({ + httpBaseUrl: record.httpBaseUrl, + bearerToken, + }), ]); useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { @@ -923,69 +1147,131 @@ async function ensureSavedEnvironmentConnection( return existing; } - const bearerToken = - options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); - if (!bearerToken) { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - authState: "requires-auth", - role: null, - connectionState: "disconnected", - lastError: "Saved environment is missing its saved credential. Pair it again.", - lastErrorAt: isoNow(), - }); - throw new Error("Saved environment is missing its saved credential."); + const pending = pendingSavedEnvironmentConnections.get(record.environmentId); + if (pending) { + return pending; } - const client = options?.client ?? createSavedEnvironmentClient(record, bearerToken); - const knownEnvironment = createKnownEnvironment({ - id: record.environmentId, - label: record.label, - source: "manual", - target: { - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - }, - }); - const connection = createEnvironmentConnection({ - kind: "saved", - knownEnvironment: { - ...knownEnvironment, - environmentId: record.environmentId, - }, - client, - refreshMetadata: async () => { - await refreshSavedEnvironmentMetadata(record, bearerToken, client); - }, - onConfigSnapshot: (config) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - descriptor: config.environment, - serverConfig: config, - }); - }, - onWelcome: (payload) => { - useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { - descriptor: payload.environment, - }); - }, - ...createEnvironmentConnectionHandlers(), - }); - - registerConnection(connection); + const nextConnection = (async () => { + let activeRecord = record; + let roleHint = options?.role ?? null; + let bearerToken = + options?.bearerToken ?? (await readSavedEnvironmentBearerToken(record.environmentId)); + if (!bearerToken) { + if (record.desktopSsh) { + const issued = await issueDesktopSshBearerSession(record); + activeRecord = issued.record; + bearerToken = issued.bearerToken; + roleHint = issued.role; + } else { + useSavedEnvironmentRuntimeStore.getState().patch(record.environmentId, { + authState: "requires-auth", + role: null, + connectionState: "disconnected", + lastError: "Saved environment is missing its saved credential. Pair it again.", + lastErrorAt: isoNow(), + }); + throw new Error("Saved environment is missing its saved credential."); + } + } else { + const prepared = await prepareSavedEnvironmentRecordForConnection(record); + activeRecord = prepared.record; + } - try { - await refreshSavedEnvironmentMetadata( - record, - bearerToken, + const activeBearerToken = bearerToken; + const client = + options?.client ?? + createSavedEnvironmentClient(activeRecord.environmentId, activeBearerToken); + const knownEnvironment = createKnownEnvironment({ + id: activeRecord.environmentId, + label: activeRecord.label, + source: "manual", + target: { + httpBaseUrl: activeRecord.httpBaseUrl, + wsBaseUrl: activeRecord.wsBaseUrl, + }, + }); + const connection = createEnvironmentConnection({ + kind: "saved", + knownEnvironment: { + ...knownEnvironment, + environmentId: activeRecord.environmentId, + }, client, - options?.role ?? null, - options?.serverConfig ?? null, - ); - return connection; - } catch (error) { - setRuntimeError(record.environmentId, error); - await removeConnection(record.environmentId).catch(() => false); - throw error; - } + refreshMetadata: async () => { + await refreshSavedEnvironmentMetadata( + activeRecord.environmentId, + activeBearerToken, + client, + ); + }, + onConfigSnapshot: (config) => { + useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { + descriptor: config.environment, + serverConfig: config, + }); + }, + onWelcome: (payload) => { + useSavedEnvironmentRuntimeStore.getState().patch(activeRecord.environmentId, { + descriptor: payload.environment, + }); + }, + ...createEnvironmentConnectionHandlers(), + }); + + try { + try { + await refreshSavedEnvironmentMetadata( + activeRecord.environmentId, + activeBearerToken, + client, + roleHint, + options?.serverConfig ?? null, + ); + } catch (error) { + const isAuthError = activeRecord.desktopSsh + ? isSshHttpAuthError(error, 401) + : isRemoteEnvironmentAuthHttpError(error) && error.status === 401; + if (!isAuthError) { + throw error; + } + if (!activeRecord.desktopSsh) { + await removeSavedEnvironmentBearerToken(activeRecord.environmentId); + throw new Error("Saved environment credential expired. Pair it again.", { + cause: error, + }); + } + + const issued = await issueDesktopSshBearerSession(activeRecord); + activeRecord = issued.record; + bearerToken = issued.bearerToken; + roleHint = issued.role; + await connection.dispose().catch(() => undefined); + pendingSavedEnvironmentConnections.delete(activeRecord.environmentId); + return await ensureSavedEnvironmentConnection(activeRecord, { + bearerToken, + role: roleHint, + serverConfig: options?.serverConfig ?? null, + }); + } + registerConnection(connection); + return connection; + } catch (error) { + setRuntimeError(activeRecord.environmentId, error); + const removed = await removeConnection(activeRecord.environmentId).catch(() => false); + if (!removed) { + await connection.dispose().catch(() => undefined); + } + throw error; + } + })(); + + pendingSavedEnvironmentConnections.set(record.environmentId, nextConnection); + return await nextConnection.finally(() => { + if (pendingSavedEnvironmentConnections.get(record.environmentId) === nextConnection) { + pendingSavedEnvironmentConnections.delete(record.environmentId); + } + }); } async function syncSavedEnvironmentConnections( @@ -1063,8 +1349,27 @@ export async function reconnectSavedEnvironment(environmentId: EnvironmentId): P setRuntimeConnecting(environmentId); try { + if (record.desktopSsh) { + await prepareSavedEnvironmentRecordForConnection(record); + } await connection.reconnect(); } catch (error) { + if (record.desktopSsh) { + try { + const issued = await issueDesktopSshBearerSession( + getSavedEnvironmentRecord(environmentId) ?? record, + ); + await removeConnection(environmentId).catch(() => false); + await ensureSavedEnvironmentConnection(issued.record, { + bearerToken: issued.bearerToken, + role: issued.role, + }); + return; + } catch (recoveryError) { + setRuntimeError(environmentId, recoveryError); + throw recoveryError; + } + } setRuntimeError(environmentId, error); throw error; } @@ -1081,33 +1386,43 @@ export async function addSavedEnvironment(input: { readonly pairingUrl?: string; readonly host?: string; readonly pairingCode?: string; + readonly desktopSsh?: DesktopSshEnvironmentTarget; }): Promise { const resolvedTarget = resolveRemotePairingTarget({ ...(input.pairingUrl !== undefined ? { pairingUrl: input.pairingUrl } : {}), ...(input.host !== undefined ? { host: input.host } : {}), ...(input.pairingCode !== undefined ? { pairingCode: input.pairingCode } : {}), }); - const descriptor = await fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - }); + const descriptor = input.desktopSsh + ? await fetchDesktopSshEnvironmentDescriptor(resolvedTarget.httpBaseUrl) + : await fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: resolvedTarget.httpBaseUrl, + }); const environmentId = descriptor.environmentId; - - if (environmentConnections.has(environmentId)) { - throw new Error("This environment is already connected."); - } - - const bearerSession = await bootstrapRemoteBearerSession({ - httpBaseUrl: resolvedTarget.httpBaseUrl, - credential: resolvedTarget.credential, - }); + const registrySnapshot = snapshotSavedEnvironmentRegistry([environmentId]); + const existingRecord = + getSavedEnvironmentRecord(environmentId) ?? + findSavedEnvironmentRecordByDesktopSshTarget(input.desktopSsh); + const staleDesktopSshRecord = + existingRecord && existingRecord.environmentId !== environmentId ? existingRecord : null; + + const bearerSession = input.desktopSsh + ? await bootstrapDesktopSshBearerSession(resolvedTarget.httpBaseUrl, resolvedTarget.credential) + : await bootstrapRemoteBearerSession({ + httpBaseUrl: resolvedTarget.httpBaseUrl, + credential: resolvedTarget.credential, + }); const record: SavedEnvironmentRecord = { environmentId, - label: input.label.trim() || descriptor.label, + label: input.label.trim() || existingRecord?.label || descriptor.label, wsBaseUrl: resolvedTarget.wsBaseUrl, httpBaseUrl: resolvedTarget.httpBaseUrl, - createdAt: isoNow(), + createdAt: existingRecord?.createdAt ?? isoNow(), lastConnectedAt: isoNow(), + ...((input.desktopSsh ?? existingRecord?.desktopSsh) + ? { desktopSsh: input.desktopSsh ?? existingRecord?.desktopSsh } + : {}), }; await persistSavedEnvironmentRecord(record); @@ -1116,26 +1431,40 @@ export async function addSavedEnvironment(input: { bearerSession.sessionToken, ); if (!didPersistBearerToken) { - await ensureLocalApi().persistence.setSavedEnvironmentRegistry( - listSavedEnvironmentRecords().map((entry) => ({ - environmentId: entry.environmentId, - label: entry.label, - httpBaseUrl: entry.httpBaseUrl, - wsBaseUrl: entry.wsBaseUrl, - createdAt: entry.createdAt, - lastConnectedAt: entry.lastConnectedAt, - })), - ); + await persistSavedEnvironmentRegistryRollback(registrySnapshot); throw new Error("Unable to persist saved environment credentials."); } + useSavedEnvironmentRegistryStore.getState().upsert(record); + if (staleDesktopSshRecord) { + await removeSavedEnvironment(staleDesktopSshRecord.environmentId); + } + await removeConnection(environmentId).catch(() => false); await ensureSavedEnvironmentConnection(record, { bearerToken: bearerSession.sessionToken, role: bearerSession.role, }); - useSavedEnvironmentRegistryStore.getState().upsert(record); return record; } +export async function connectDesktopSshEnvironment( + target: DesktopSshEnvironmentTarget, + options?: { label?: string }, +): Promise { + const bootstrap = await resolveDesktopSshEnvironmentBootstrap(target, { + issuePairingToken: true, + }); + if (!bootstrap.pairingToken) { + throw new Error("Desktop SSH launch did not return a pairing token."); + } + + return await addSavedEnvironment({ + label: options?.label?.trim() || bootstrap.target.alias, + host: bootstrap.httpBaseUrl, + pairingCode: bootstrap.pairingToken, + desktopSsh: bootstrap.target, + }); +} + export async function ensureEnvironmentConnectionBootstrapped( environmentId: EnvironmentId, ): Promise { @@ -1211,6 +1540,7 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () export async function resetEnvironmentServiceForTests(): Promise { stopActiveService(); lastAppliedProjectionVersionByEnvironment.clear(); + pendingSavedEnvironmentConnections.clear(); for (const key of Array.from(threadDetailSubscriptions.keys())) { disposeThreadDetailSubscriptionByKey(key); } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index de5f057875e..a02241315ff 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -169,6 +169,24 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg getSavedEnvironmentSecret: async () => null, setSavedEnvironmentSecret: async () => true, removeSavedEnvironmentSecret: async () => undefined, + discoverSshHosts: async () => [], + ensureSshEnvironment: async () => { + throw new Error("ensureSshEnvironment not implemented in test"); + }, + fetchSshEnvironmentDescriptor: async () => { + throw new Error("fetchSshEnvironmentDescriptor not implemented in test"); + }, + bootstrapSshBearerSession: async () => { + throw new Error("bootstrapSshBearerSession not implemented in test"); + }, + fetchSshSessionState: async () => { + throw new Error("fetchSshSessionState not implemented in test"); + }, + issueSshWebSocketToken: async () => { + throw new Error("issueSshWebSocketToken not implemented in test"); + }, + onSshPasswordPrompt: () => () => undefined, + resolveSshPasswordPrompt: async () => undefined, getServerExposureState: async () => ({ mode: "local-only", endpointUrl: null, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index df62be29dfa..7c38d77853a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,6 +13,7 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -145,6 +146,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1aeaf02a45b..f6cdbacfa7c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -52,9 +52,16 @@ import type { OrchestrationThreadStreamItem, } from "./orchestration.ts"; import type { EnvironmentId } from "./baseSchemas.ts"; +import type { + AuthBearerBootstrapResult, + AuthSessionState, + AuthWebSocketTokenResult, +} from "./auth.ts"; import type { AdvertisedEndpoint } from "./remoteAccess.ts"; import { EditorId } from "./editor.ts"; -import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; +import type { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import type { ClientSettings } from "./settings.ts"; +import { ServerSettings, ServerSettingsPatch } from "./settings.ts"; export interface ContextMenuItem { id: T; @@ -126,6 +133,33 @@ export interface DesktopEnvironmentBootstrap { bootstrapToken?: string; } +export interface DesktopSshEnvironmentTarget { + alias: string; + hostname: string; + username: string | null; + port: number | null; +} + +export type DesktopSshHostSource = "ssh-config" | "known-hosts"; + +export interface DesktopDiscoveredSshHost extends DesktopSshEnvironmentTarget { + source: DesktopSshHostSource; +} + +export interface DesktopSshEnvironmentBootstrap { + target: DesktopSshEnvironmentTarget; + httpBaseUrl: string; + wsBaseUrl: string; + pairingToken: string | null; +} + +export interface DesktopSshPasswordPromptRequest { + requestId: string; + destination: string; + username: string | null; + prompt: string; +} + export interface PersistedSavedEnvironmentRecord { environmentId: EnvironmentId; label: string; @@ -133,6 +167,7 @@ export interface PersistedSavedEnvironmentRecord { httpBaseUrl: string; createdAt: string; lastConnectedAt: string | null; + desktopSsh?: DesktopSshEnvironmentTarget; } export type DesktopServerExposureMode = "local-only" | "network-accessible"; @@ -159,6 +194,23 @@ export interface DesktopBridge { getSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; setSavedEnvironmentSecret: (environmentId: EnvironmentId, secret: string) => Promise; removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; + discoverSshHosts: () => Promise; + ensureSshEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { issuePairingToken?: boolean }, + ) => Promise; + fetchSshEnvironmentDescriptor: (httpBaseUrl: string) => Promise; + bootstrapSshBearerSession: ( + httpBaseUrl: string, + credential: string, + ) => Promise; + fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise; + issueSshWebSocketToken: ( + httpBaseUrl: string, + bearerToken: string, + ) => Promise; + onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; + resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; getAdvertisedEndpoints: () => Promise;