From 65600ed324c4d1f5f5c0863686d6007c1c88e35e Mon Sep 17 00:00:00 2001 From: will Date: Sat, 15 Aug 2026 01:12:54 -0400 Subject: [PATCH 01/26] fix(desktop): reuse remote environments over ssh --- .../src/app/DesktopConnectionCatalogStore.ts | 1 + apps/mobile/src/connection/storage.ts | 19 +- .../settings/ConnectionsSettings.tsx | 98 +++++++++- apps/web/src/connection/storage.test.ts | 1 + apps/web/src/connection/storage.ts | 12 ++ docs/internals/connection-runtime.md | 8 + docs/internals/remote.md | 21 ++- docs/user/remote-access.md | 22 ++- .../client-runtime/src/connection/driver.ts | 8 +- .../client-runtime/src/connection/index.ts | 1 + .../client-runtime/src/connection/model.ts | 12 ++ .../src/connection/registry.test.ts | 169 ++++++++++++++++-- .../client-runtime/src/connection/registry.ts | 166 ++++++++++++++--- .../src/connection/supervisor.test.ts | 40 ++++- .../src/connection/supervisor.ts | 9 +- .../src/platform/persistence.ts | 8 +- .../src/platform/storageDocument.test.ts | 30 +++- .../src/platform/storageDocument.ts | 83 ++++++--- .../client-runtime/src/state/connections.ts | 33 ++++ packages/ssh/src/tunnel.test.ts | 29 ++- packages/ssh/src/tunnel.ts | 147 ++++++++++++++- 21 files changed, 830 insertions(+), 87 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 5ec2edb595ff..fa0252059ef4 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -369,6 +369,7 @@ const migrateSavedEnvironmentRecords = Effect.fn( return { schemaVersion: 1, targets, + routes: targets, profiles, credentials, remoteDpopTokens: [], diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index e844181673df..ce370cb25186 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -6,6 +6,8 @@ import { removeConnectionFromCatalog, removeCatalogValue, replaceCatalogValue, + selectConnectionRouteInCatalog, + connectionRoutes, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { @@ -20,7 +22,12 @@ import * as Option from "effect/Option"; import * as CatalogStore from "./catalog-store"; function targetPersistenceError( - operation: "list-targets" | "register-connection" | "remove-connection", + operation: + | "list-targets" + | "list-routes" + | "register-connection" + | "select-connection-route" + | "remove-connection", error: ConnectionTransientError, ) { return new ConnectionPersistenceError({ @@ -38,12 +45,22 @@ export const connectionStorageLayer = Layer.effectContext( Effect.map((document) => document.targets), Effect.mapError((error) => targetPersistenceError("list-targets", error)), ), + listRoutes: catalog.read.pipe( + Effect.map(connectionRoutes), + Effect.mapError((error) => targetPersistenceError("list-routes", error)), + ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => catalog .update((document) => registerConnectionInCatalog(document, registration)) .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + select: (target) => + catalog + .update((document) => selectConnectionRouteInCatalog(document, target)) + .pipe( + Effect.mapError((error) => targetPersistenceError("select-connection-route", error)), + ), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 18d1b0f1c924..540b1cac612c 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -28,7 +28,12 @@ import { type DesktopWslState, type EnvironmentId, } from "@t3tools/contracts"; -import { connectionStatusText } from "@t3tools/client-runtime/connection"; +import { + type ConnectionCatalogEntry, + connectionRouteId, + connectionStatusText, +} from "@t3tools/client-runtime/connection"; +import type { Discovery } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1350,16 +1355,39 @@ function NetworkAccessDescription({ type SavedBackendListRowProps = { environment: EnvironmentPresentation; + routes: ReadonlyArray; + relayEnvironment: Discovery.RelayDiscoveredEnvironment | null; removingEnvironmentId: EnvironmentId | null; + switchingRouteEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; + onSelectRoute: (environmentId: EnvironmentId, routeId: string) => void; }; +function connectionRouteLabel(entry: ConnectionCatalogEntry): string { + switch (entry.target._tag) { + case "RelayConnectionTarget": + return "T3 Connect"; + case "SshConnectionTarget": + return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile" + ? `SSH ${formatDesktopSshTarget(entry.profile.value.target)}` + : "SSH"; + case "BearerConnectionTarget": + return "Direct"; + case "PrimaryConnectionTarget": + return "Local"; + } +} + function SavedBackendListRow({ environment, + routes, + relayEnvironment, removingEnvironmentId, + switchingRouteEnvironmentId, onConnect, onRemove, + onSelectRoute, }: SavedBackendListRowProps) { const environmentId = environment.environmentId; const connectionState = environment.connection.phase; @@ -1478,6 +1506,39 @@ function SavedBackendListRow({ ) : null}
+ {routes.length > 1 ? ( + + ) : null} {versionMismatch && (serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? ( (null); + const [switchingRouteEnvironmentId, setSwitchingRouteEnvironmentId] = + useState(null); const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< @@ -2265,6 +2332,29 @@ export function ConnectionsSettings() { [retryEnvironment], ); + const handleSelectSavedBackendRoute = useCallback( + async (environmentId: EnvironmentId, routeId: string) => { + setSwitchingRouteEnvironmentId(environmentId); + setSavedBackendError(null); + const result = await selectEnvironmentRoute({ environmentId, routeId }); + setSwitchingRouteEnvironmentId(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + const message = + error instanceof Error ? error.message : "Failed to switch connection method."; + setSavedBackendError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not switch connection method", + description: message, + }), + ); + } + }, + [selectEnvironmentRoute], + ); + const handleRemoveSavedBackend = useCallback( async (environmentId: EnvironmentId) => { setRemovingSavedEnvironmentId(environmentId); @@ -3448,9 +3538,15 @@ export function ConnectionsSettings() { ))} document.targets), Effect.mapError((cause) => persistenceError("list-targets", cause)), ), + listRoutes: catalog.read.pipe( + Effect.map(connectionRoutes), + Effect.mapError((cause) => persistenceError("list-routes", cause)), + ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => catalog .update((document) => registerConnectionInCatalog(document, registration)) .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + select: (target) => + catalog + .update((document) => selectConnectionRouteInCatalog(document, target)) + .pipe(Effect.mapError((cause) => persistenceError("select-connection-route", cause))), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 46fe0c82716a..6f9cf052421e 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -35,6 +35,14 @@ scope when it changed. `createServiceScope` builds an `EnvironmentSupervisor` bound to a closeable scope and connects it; `run` and `runStream` execute caller effects with that supervisor provided. +Persisted environments may have multiple routes but exactly one selected target +per client. `EnvironmentRegistry.routes` publishes the retained route entries. +`selectRoute` first calls `ConnectionDriver.prepare` while the selected +supervisor remains live, so authentication and stable environment identity are +verified before persistence changes. It then swaps the selected entry and +supervisor. Selection never falls back automatically; a failed preparation +leaves the existing session and persisted selection untouched. + `EnvironmentSupervisor` owns desired state, retry scheduling, and the active session scope. React components do not create connections, transports, retry loops, or RPC clients. diff --git a/docs/internals/remote.md b/docs/internals/remote.md index afce95f725bc..da2ba5c438a7 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -57,8 +57,10 @@ control plane or a copy of session state. | `RelayConnectionTarget` | Managed T3 Connect relay tunnels. | | `SshConnectionTarget` | Desktop-managed SSH environments. | -Bearer, relay, and SSH are persisted; primary is platform-managed. Note that Tailscale is not a -separate target kind. A Tailscale URL is paired through the ordinary bearer path in +Bearer, relay, and SSH routes are persisted; primary is platform-managed. The catalog keeps one +selected target per environment plus every known route for that stable environment ID. Registering +an SSH route for an environment already reached through the relay retains both routes instead of +replacing the relay registration. Note that Tailscale is not a separate target kind. A Tailscale URL is paired through the ordinary bearer path in [`onboarding.ts`][onboarding] (`preparePairingRegistration`), which accepts either a pairing URL or a host plus pairing code. Tailscale is an endpoint provider and transport, not a distinct runtime concept. @@ -166,6 +168,14 @@ server, opens a local tunnel, checks HTTP readiness, optionally issues a remote returns local HTTP/WS endpoints. Disconnect closes the tunnel and stops the remote server if the launcher started it; a server that was already running (marked `external`) is left running. +Remote discovery checks the user-level `t3code.service` process first, then the SSH environment and +same-user `/proc` metadata for `T3CODE_HOME` values. Each recorded runtime is accepted only when its +PID is live and its loopback environment descriptor answers. The discovered base directory is saved +for `t3 pair --base-dir`, so the pairing credential lands in the database the reused server actually +reads. The launcher reuses that reported port before considering a new server. This is what prevents +an SSH access path from creating a second logical environment beside an already-running T3 Connect +service whose private service environment is not inherited by SSH. + The desktop main process owns this because it can spawn SSH, manage prompts, write launch scripts, and clean up forwards. The renderer connects through the forwarded URL like any other environment and needs no SSH-specific RPC path. @@ -175,6 +185,13 @@ failure includes launcher output where available, forwarded-port failure leaves disconnected rather than falling back to an unrelated endpoint, and reconnect restores the SSH bridge before reconnecting the WebSocket client. +Route selection is manual and device-local. `EnvironmentRegistry.selectRoute` prepares and +authenticates the candidate while the current supervisor remains active, including verifying its +environment ID. Only then does it persist the selected target and replace the supervisor. There is +one active RPC session per environment on a client; an inactive SSH tunnel may remain warm for a +later manual switch, but it owns no subscriptions. A failed candidate leaves the selected route and +active session unchanged. Mobile shares the route-capable catalog model but exposes no SSH UI. + ## Launch methods Launch answers a different question: how does a T3 server come to exist on the target machine? Keep diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 5993fca5b352..674c466ebf11 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -136,10 +136,27 @@ After setup, the renderer connects to a local forwarded HTTP/WebSocket endpoint. 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. +If the same server is already saved through T3 Connect, adding it through SSH does not create a +second environment. The desktop keeps both access methods under the server's stable environment +identity. Choose **T3 Connect** or **SSH** from the environment's connection-method menu. The choice +is manual and applies only to that desktop; T3 Code does not silently fall back to the other method. +Before switching, the desktop prepares the selected route and verifies that it reaches the same +environment, then hands the active session over. If preparation fails, the current connection stays +active. + +All clients still talk to the same T3 server, regardless of their route. A thread created by the +desktop over SSH is therefore available to mobile over T3 Connect, and server events continue to fan +out to every connected client. + #### SSH Launch Troubleshooting The desktop SSH launcher connects with a non-interactive `sh` session, writes a small launcher script under `~/.t3/ssh-launch//`, starts or reuses a remote T3 server, and forwards the remote loopback port back to your desktop. +Before starting another server, the launcher inspects the remote T3 service and running-process +metadata, then validates the recorded server through its environment descriptor. This lets SSH reuse +a server started by T3 Connect even when that service uses a private `T3CODE_HOME` that the SSH login +shell does not inherit; the forwarded remote port is the port reported by that server. + The remote host must have a compatible Node.js runtime. T3 Code uses the server package's `engines.node` requirement: ```text @@ -165,7 +182,10 @@ nvm alias default 24 With mise, asdf, fnm, or nodenv, make sure the tool's shim directory is installed and resolves to a Node version satisfying the range above without an interactive shell. -If reconnecting after an app update fails, retry the SSH launch once. The launcher now compares its generated runner script, stops stale launcher-managed remote servers, clears the SSH launch PID/port state, and starts a fresh remote server. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually. +If reconnecting after an app update fails, use **Connect** to retry. The launcher compares its +generated runner script, clears stale launcher state, and starts a fresh server only when it cannot +discover or reuse a live one. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` +processes manually. ## Updating a Remote Server diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index f29f913dd548..01d94e81465a 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -29,6 +29,9 @@ export interface EnvironmentConnectionLease { export class ConnectionDriver extends Context.Service< ConnectionDriver, { + readonly prepare: ( + entry: ConnectionCatalogEntry, + ) => Effect.Effect; readonly connect: ( entry: ConnectionCatalogEntry, reportProgress: (progress: ConnectionDriverProgress) => Effect.Effect, @@ -39,6 +42,7 @@ export class ConnectionDriver extends Context.Service< export const make = Effect.gen(function* () { const resolver = yield* ConnectionResolver.ConnectionResolver; const sessions = yield* RpcSession.RpcSessionFactory; + const prepare = resolver.prepare; const connect = Effect.fn("ConnectionDriver.connect")(function* ( entry: ConnectionCatalogEntry, @@ -50,7 +54,7 @@ export const make = Effect.gen(function* () { "connection.target.kind": target._tag, }); yield* reportProgress({ stage: "preparing" }); - const prepared = yield* resolver.prepare(entry); + const prepared = yield* prepare(entry); yield* reportProgress({ stage: "opening", prepared }); const session = yield* sessions.connect(prepared); yield* reportProgress({ stage: "synchronizing", prepared }); @@ -58,7 +62,7 @@ export const make = Effect.gen(function* () { return { prepared, session } satisfies EnvironmentConnectionLease; }); - return ConnectionDriver.of({ connect }); + return ConnectionDriver.of({ prepare, connect }); }); export const layer = Layer.effect(ConnectionDriver, make); diff --git a/packages/client-runtime/src/connection/index.ts b/packages/client-runtime/src/connection/index.ts index 53a041bbf307..9f7a0787b607 100644 --- a/packages/client-runtime/src/connection/index.ts +++ b/packages/client-runtime/src/connection/index.ts @@ -24,6 +24,7 @@ export { export * from "./presentation.ts"; export * as ProfileStore from "./profileStore.ts"; export { + ConnectionRouteNotRegisteredError, EnvironmentNotRegisteredError, EnvironmentRegistry, PlatformEnvironmentRemovalError, diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index fbcb302ed135..722b5cea2cd1 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -55,6 +55,18 @@ export type PersistedConnectionTarget = typeof PersistedConnectionTarget.Type; export type ConnectionTargetKind = ConnectionTarget["_tag"]; +export function connectionRouteId(target: ConnectionTarget): string { + switch (target._tag) { + case "PrimaryConnectionTarget": + return `primary:${target.environmentId}`; + case "RelayConnectionTarget": + return `relay:${target.environmentId}`; + case "BearerConnectionTarget": + case "SshConnectionTarget": + return target.connectionId; + } +} + export type NetworkStatus = "unknown" | "offline" | "online"; export const ConnectionTransientReason = Schema.Literals([ diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 9354db9c998a..e278fe611acf 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -39,6 +39,7 @@ import { type ConnectionTarget, type PreparedConnection, type SupervisorConnectionState, + connectionRouteId, } from "./model.ts"; import * as Persistence from "../platform/persistence.ts"; import * as ConnectionProfileStore from "./profileStore.ts"; @@ -105,6 +106,10 @@ const SSH_CONNECTION = new SshConnectionTarget({ label: "SSH environment", connectionId: "ssh-connection", }); +const SSH_RELAY_TARGET = new RelayConnectionTarget({ + environmentId: SSH_CONNECTION.environmentId, + label: SSH_CONNECTION.label, +}); const SSH_PROFILE = new SshConnectionProfile({ connectionId: SSH_CONNECTION.connectionId, environmentId: SSH_CONNECTION.environmentId, @@ -128,6 +133,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( initialProfiles: ReadonlyArray = [], initialCredentials: ReadonlyArray = [], options?: { + readonly initialRoutes?: ReadonlyArray; + readonly prepareRoute?: ( + target: ConnectionTarget, + ) => Effect.Effect; readonly beforeSessionConnect?: (environmentId: EnvironmentId) => Effect.Effect; readonly beforeRegistrationRegister?: ( registration: ConnectionRegistration, @@ -140,6 +149,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const storedTargets = yield* Ref.make( new Map(initialTargets.map((target) => [target.environmentId, target])), ); + const storedRoutes = yield* Ref.make( + new Map( + (options?.initialRoutes ?? initialTargets).map((target) => [ + connectionRouteId(target), + target, + ]), + ), + ); const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); const cacheClears = yield* Ref.make>([]); const ownedDataClears = yield* Ref.make>([]); @@ -173,6 +190,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const targetStore = Persistence.ConnectionTargetStore.of({ list: Ref.get(storedTargets).pipe(Effect.map((targets) => [...targets.values()])), + listRoutes: Ref.get(storedRoutes).pipe(Effect.map((routes) => [...routes.values()])), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ register: (registration) => @@ -183,6 +201,11 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( next.set(registration.target.environmentId, registration.target); return next; }); + yield* Ref.update(storedRoutes, (current) => { + const next = new Map(current); + next.set(connectionRouteId(registration.target), registration.target); + return next; + }); switch (registration._tag) { case "RelayConnectionRegistration": return; @@ -206,26 +229,48 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( }); } }), + select: (target) => + Ref.update(storedTargets, (current) => { + const next = new Map(current); + next.set(target.environmentId, target); + return next; + }), remove: (target) => Effect.gen(function* () { yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; + const removedConnectionIds = new Set( + [...(yield* Ref.get(storedRoutes)).values()] + .filter((route) => route.environmentId === target.environmentId) + .flatMap((route) => + route._tag === "BearerConnectionTarget" || route._tag === "SshConnectionTarget" + ? [route.connectionId] + : [], + ), + ); yield* Ref.update(storedTargets, (current) => { const next = new Map(current); next.delete(target.environmentId); return next; }); - if (target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget") { - yield* Ref.update(storedProfiles, (current) => { - const next = new Map(current); - next.delete(target.connectionId); - return next; - }); - yield* Ref.update(storedCredentials, (current) => { - const next = new Map(current); - next.delete(target.connectionId); - return next; - }); - } + yield* Ref.update(storedRoutes, (current) => { + const next = new Map(current); + for (const [routeId, route] of next) { + if (route.environmentId === target.environmentId) { + next.delete(routeId); + } + } + return next; + }); + yield* Ref.update(storedProfiles, (current) => { + const next = new Map(current); + for (const connectionId of removedConnectionIds) next.delete(connectionId); + return next; + }); + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + for (const connectionId of removedConnectionIds) next.delete(connectionId); + return next; + }); yield* Ref.update(storedRemoteTokens, (current) => { const next = new Map(current); next.delete(target.environmentId); @@ -334,6 +379,14 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( disconnect: (target) => Ref.update(disconnectedSshTargets, (current) => [...current, target]), }); const driver = ConnectionDriver.ConnectionDriver.of({ + prepare: (entry) => + options?.prepareRoute?.(entry.target) ?? + Effect.succeed({ + ...PREPARED, + environmentId: entry.target.environmentId, + label: entry.target.label, + target: entry.target, + }), connect: (entry, reportProgress) => Effect.gen(function* () { const target = entry.target; @@ -597,6 +650,95 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("prepares and verifies a route before replacing the active session", () => + Effect.gen(function* () { + const prepareStarted = yield* Deferred.make(); + const releasePreparation = yield* Deferred.make(); + const harness = yield* makeHarness([SSH_RELAY_TARGET], [SSH_PROFILE], [], { + initialRoutes: [SSH_RELAY_TARGET, SSH_CONNECTION], + prepareRoute: (target) => + Deferred.succeed(prepareStarted, undefined).pipe( + Effect.andThen(Deferred.await(releasePreparation)), + Effect.as({ + ...PREPARED, + environmentId: target.environmentId, + label: target.label, + target, + }), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + SSH_RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const selection = yield* Effect.forkChild( + registry.selectRoute(SSH_RELAY_TARGET.environmentId, connectionRouteId(SSH_CONNECTION)), + ); + yield* Deferred.await(prepareStarted); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + yield* Deferred.succeed(releasePreparation, undefined); + yield* Fiber.join(selection); + yield* awaitConnectionState( + registry, + SSH_CONNECTION.environmentId, + (state) => state.phase === "connected", + ); + + expect((yield* Ref.get(harness.storedTargets)).get(SSH_CONNECTION.environmentId)).toEqual( + SSH_CONNECTION, + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(SSH_CONNECTION.environmentId)?.target, + ).toEqual(SSH_CONNECTION); + expect(yield* Ref.get(harness.releasedSessions)).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("keeps the active route when candidate preparation fails", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_RELAY_TARGET], [SSH_PROFILE], [], { + initialRoutes: [SSH_RELAY_TARGET, SSH_CONNECTION], + prepareRoute: () => + Effect.fail( + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: "SSH is unavailable.", + }), + ), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + SSH_RELAY_TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const failure = yield* Effect.flip( + registry.selectRoute(SSH_RELAY_TARGET.environmentId, connectionRouteId(SSH_CONNECTION)), + ); + + expect(failure).toBeInstanceOf(ConnectionTransientError); + expect((yield* Ref.get(harness.storedTargets)).get(SSH_CONNECTION.environmentId)).toEqual( + SSH_RELAY_TARGET, + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(SSH_CONNECTION.environmentId)?.target, + ).toEqual(SSH_RELAY_TARGET); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("moves durable streams to a replacement supervisor", () => Effect.gen(function* () { const replacement = new RelayConnectionTarget({ @@ -916,7 +1058,7 @@ describe("EnvironmentRegistry", () => { it.effect("removes all owned SSH state only on explicit removal", () => Effect.gen(function* () { const harness = yield* makeHarness( - [SSH_CONNECTION], + [SSH_RELAY_TARGET], [SSH_PROFILE], [ [ @@ -924,6 +1066,7 @@ describe("EnvironmentRegistry", () => { new BearerConnectionCredential({ token: "temporary-token" }), ], ], + { initialRoutes: [SSH_RELAY_TARGET, SSH_CONNECTION] }, ); yield* Effect.gen(function* () { diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index a3a36272f326..5be46cc5e7c7 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -30,6 +30,7 @@ import type { NetworkStatus, SupervisorConnectionState, } from "./model.ts"; +import { connectionRouteId } from "./model.ts"; import * as Persistence from "../platform/persistence.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionDriver from "./driver.ts"; @@ -59,17 +60,43 @@ export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( + "ConnectionRouteNotRegisteredError", + { + environmentId: EnvironmentId, + routeId: Schema.String, + }, +) { + override get message(): string { + return `Route ${this.routeId} is not registered for environment ${this.environmentId}.`; + } +} + export class EnvironmentRegistry extends Context.Service< EnvironmentRegistry, { readonly entries: SubscriptionRef.SubscriptionRef< ReadonlyMap >; + readonly routes: SubscriptionRef.SubscriptionRef< + ReadonlyMap> + >; readonly networkStatus: SubscriptionRef.SubscriptionRef; readonly start: Effect.Effect; readonly register: ( registration: ConnectionRegistration, ) => Effect.Effect; + readonly selectRoute: ( + environmentId: EnvironmentId, + routeId: string, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | ConnectionRouteNotRegisteredError + | PlatformEnvironmentRemovalError + >; readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, @@ -137,24 +164,41 @@ export const make = Effect.gen(function* () { const wakeups = yield* ConnectionWakeups.ConnectionWakeups; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const persistedTargets = yield* storage.list; + const persistedRoutes = yield* storage.listRoutes; + const loadCatalogEntry = Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* ( + target: ConnectionTarget, + ) { + const profile = + target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" + ? yield* profiles.get(target.connectionId) + : Option.none(); + return { target, profile } satisfies ConnectionCatalogEntry; + }); const initialEntries = new Map( yield* Effect.forEach( persistedTargets, - Effect.fn("EnvironmentRegistry.loadCatalogEntry")(function* (target) { - const profile = - target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" - ? yield* profiles.get(target.connectionId) - : Option.none(); - return [ - target.environmentId, - { target, profile } satisfies ConnectionCatalogEntry, - ] as const; - }), + (target) => + loadCatalogEntry(target).pipe( + Effect.map((entry) => [target.environmentId, entry] as const), + ), { concurrency: "unbounded" }, ), ); + const initialRoutes = new Map>(); + for (const entry of yield* Effect.forEach(persistedRoutes, loadCatalogEntry, { + concurrency: "unbounded", + })) { + initialRoutes.set(entry.target.environmentId, [ + ...(initialRoutes.get(entry.target.environmentId) ?? []), + entry, + ]); + } const entries = yield* SubscriptionRef.make>(initialEntries); + const routes = + yield* SubscriptionRef.make>>( + initialRoutes, + ); const networkStatus = yield* SubscriptionRef.make(yield* connectivity.status); const serviceScopes = yield* SubscriptionRef.make< ReadonlyMap @@ -403,6 +447,57 @@ export const make = Effect.gen(function* () { next.set(environmentId, registration.target); return next; }); + yield* SubscriptionRef.update(routes, (current) => { + const next = new Map(current); + const existing = next.get(environmentId) ?? []; + next.set(environmentId, [ + ...existing.filter( + (candidate) => + connectionRouteId(candidate.target) !== connectionRouteId(entry.target), + ), + entry, + ]); + return next; + }); + yield* installEntryLocked(entry); + }), + ); + }); + + const selectRoute = Effect.fn("EnvironmentRegistry.selectRoute")(function* ( + environmentId: EnvironmentId, + routeId: string, + ) { + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return yield* new PlatformEnvironmentRemovalError({ environmentId }); + } + yield* getEntry(environmentId); + const entry = (yield* SubscriptionRef.get(routes)) + .get(environmentId) + ?.find((candidate) => connectionRouteId(candidate.target) === routeId); + if (entry === undefined) { + return yield* new ConnectionRouteNotRegisteredError({ environmentId, routeId }); + } + if (entry.target._tag === "PrimaryConnectionTarget") { + return yield* new PlatformEnvironmentRemovalError({ environmentId }); + } + const selected = (yield* SubscriptionRef.get(entries)).get(environmentId); + if (selected !== undefined && Equal.equals(selected, entry)) { + return; + } + + // Keep the current session active while credentials, transport, and the + // stable environment identity for the candidate route are verified. + yield* driver.prepare(entry); + yield* registrations.select(entry.target); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.set(environmentId, entry.target); + return next; + }); yield* installEntryLocked(entry); }), ); @@ -459,6 +554,11 @@ export const make = Effect.gen(function* () { ), ); } + yield* SubscriptionRef.update(routes, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); yield* installEntryLocked(entry, { retainEquivalentRuntime: true }); }), @@ -551,10 +651,14 @@ export const make = Effect.gen(function* () { }); } const target = (yield* getEntry(environmentId)).target; - const profile = - target._tag === "BearerConnectionTarget" || target._tag === "SshConnectionTarget" - ? yield* profiles.get(target.connectionId) - : Option.none(); + const sshTargets = ((yield* SubscriptionRef.get(routes)).get(environmentId) ?? []).flatMap( + (entry) => + entry.target._tag === "SshConnectionTarget" && + Option.isSome(entry.profile) && + isSshConnectionProfile(entry.profile.value) + ? [entry.profile.value.target] + : [], + ); yield* registrations.remove(target); yield* Ref.update(persistedTargetsByEnvironment, (current) => { @@ -568,6 +672,11 @@ export const make = Effect.gen(function* () { next.delete(environmentId); return next; }); + yield* SubscriptionRef.update(routes, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); yield* Effect.all( [ cache.clear(environmentId).pipe( @@ -583,21 +692,20 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded", discard: true }, ); - if ( - target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - isSshConnectionProfile(profile.value) - ) { - yield* ssh.disconnect(profile.value.target).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not disconnect the managed SSH environment.", { - environmentId, - error, - }), + yield* Effect.forEach( + sshTargets, + (sshTarget) => + ssh.disconnect(sshTarget).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, ), - Effect.ignore, - ); - } + { discard: true }, + ); }), ); }); @@ -659,9 +767,11 @@ export const make = Effect.gen(function* () { return EnvironmentRegistry.of({ entries, + routes, networkStatus, start, register, + selectRoute, registerPlatform, reconcilePlatform, remove, diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 5e50c44d9610..e8a13a9c8a32 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -20,6 +20,7 @@ import { ConnectionTransientError, PrimaryConnectionTarget, RelayConnectionTarget, + SshConnectionTarget, type ConnectionAttemptError, type ConnectionTarget, type NetworkStatus, @@ -52,6 +53,15 @@ const RELAY_ENTRY: ConnectionCatalogEntry = { profile: Option.none(), }; +const SSH_ENTRY: ConnectionCatalogEntry = { + target: new SshConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, + connectionId: "ssh:test-environment", + }), + profile: Option.none(), +}; + const PREPARED_CONNECTION: PreparedConnection = { environmentId: TARGET.environmentId, label: TARGET.label, @@ -188,7 +198,10 @@ const makeHarness = Effect.fn("TestConnectionHarness.make")(function* (options?: ), Layer.succeed( ConnectionDriver.ConnectionDriver, - ConnectionDriver.ConnectionDriver.of({ connect }), + ConnectionDriver.ConnectionDriver.of({ + prepare: (entry) => prepare(entry.target), + connect, + }), ), ); @@ -465,6 +478,31 @@ describe("EnvironmentSupervisor", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("allows SSH launch longer than the standard setup timeout", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + prepare: () => Effect.never, + }); + const supervisor = yield* EnvironmentSupervisor.make(SSH_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* awaitState( + supervisor.state, + (state) => state.phase === "connecting" && state.stage === "preparing", + ); + yield* TestClock.adjust("119 seconds"); + expect((yield* SubscriptionRef.get(supervisor.state)).phase).toBe("connecting"); + + yield* TestClock.adjust("1 second"); + const retrying = yield* eventuallyState( + supervisor.state, + (state) => state.phase === "backoff" && state.attempt === 1, + ); + expect(retrying.lastFailure?.reason).toBe("timeout"); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("converts unexpected driver defects into retryable failures", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 85fda10ef1a7..4c1c92adc915 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -31,6 +31,7 @@ import * as ConnectionWakeups from "./wakeups.ts"; const RETRY_DELAYS_MS = [3_000, 4_000, 8_000, 16_000] as const; const CONNECTION_ESTABLISHMENT_TIMEOUT = "15 seconds"; +const SSH_CONNECTION_ESTABLISHMENT_TIMEOUT = "120 seconds"; const CONNECTION_PROBE_TIMEOUT = "15 seconds"; const MOBILE_CONNECTION_PROBE_TIMEOUT = "3 seconds"; const BACKOFF_RESET_AFTER_MS = 30_000; @@ -513,9 +514,11 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }), ), ), - Effect.sleep(CONNECTION_ESTABLISHMENT_TIMEOUT).pipe( - Effect.as({ _tag: "TimedOut" }), - ), + Effect.sleep( + target._tag === "SshConnectionTarget" + ? SSH_CONNECTION_ESTABLISHMENT_TIMEOUT + : CONNECTION_ESTABLISHMENT_TIMEOUT, + ).pipe(Effect.as({ _tag: "TimedOut" })), ]); if (establishment._tag === "Interrupted") { diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 14a322bcd303..7e59c8db11c3 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -12,14 +12,16 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; -import type { ConnectionTarget } from "../connection/model.ts"; +import type { ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( "ConnectionPersistenceError", { operation: Schema.Literals([ "list-targets", + "list-routes", "register-connection", + "select-connection-route", "remove-connection", "load-shell", "save-shell", @@ -42,6 +44,7 @@ export class ConnectionTargetStore extends Context.Service< ConnectionTargetStore, { readonly list: Effect.Effect, ConnectionPersistenceError>; + readonly listRoutes: Effect.Effect, ConnectionPersistenceError>; } >()("@t3tools/client-runtime/platform/persistence/ConnectionTargetStore") {} @@ -51,6 +54,9 @@ export class ConnectionRegistrationStore extends Context.Service< readonly register: ( registration: ConnectionRegistration, ) => Effect.Effect; + readonly select: ( + target: PersistedConnectionTarget, + ) => Effect.Effect; readonly remove: (target: ConnectionTarget) => Effect.Effect; } >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 8ad6b81e12b4..7d6f5dd9afb1 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; import * as TokenStore from "../authorization/tokenStore.ts"; import { @@ -17,6 +18,8 @@ import { } from "../connection/model.ts"; import { EMPTY_CONNECTION_CATALOG_DOCUMENT, + ConnectionCatalogDocument, + connectionRoutes, registerConnectionInCatalog, removeConnectionFromCatalog, } from "./storageDocument.ts"; @@ -52,6 +55,19 @@ const REMOTE_TOKEN = new TokenStore.RemoteDpopAccessToken({ }); describe("ConnectionCatalogDocument", () => { + it("treats targets from a legacy catalog as its initial routes", () => { + const decoded = Schema.decodeUnknownSync(ConnectionCatalogDocument)({ + schemaVersion: 1, + targets: [BEARER_TARGET], + profiles: [BEARER_PROFILE], + credentials: [], + remoteDpopTokens: [], + }); + + expect(decoded.routes).toEqual([]); + expect(connectionRoutes(decoded)).toEqual([BEARER_TARGET]); + }); + it("registers a bearer connection as one catalog mutation", () => { const document = registerConnectionInCatalog( EMPTY_CONNECTION_CATALOG_DOCUMENT, @@ -63,6 +79,7 @@ describe("ConnectionCatalogDocument", () => { ); expect(document.targets).toEqual([BEARER_TARGET]); + expect(document.routes).toEqual([BEARER_TARGET]); expect(document.profiles).toEqual([BEARER_PROFILE]); expect(document.credentials).toEqual([ { @@ -72,7 +89,7 @@ describe("ConnectionCatalogDocument", () => { ]); }); - it("replaces obsolete connection metadata without discarding a reusable DPoP token", () => { + it("selects a newly registered route without discarding other routes", () => { const bearer = registerConnectionInCatalog( { ...EMPTY_CONNECTION_CATALOG_DOCUMENT, @@ -94,8 +111,14 @@ describe("ConnectionCatalogDocument", () => { ); expect(relay.targets).toEqual([relayTarget]); - expect(relay.profiles).toEqual([]); - expect(relay.credentials).toEqual([]); + expect(relay.routes).toEqual([BEARER_TARGET, relayTarget]); + expect(relay.profiles).toEqual([BEARER_PROFILE]); + expect(relay.credentials).toEqual([ + { + connectionId: BEARER_TARGET.connectionId, + credential: BEARER_CREDENTIAL, + }, + ]); expect(relay.remoteDpopTokens).toEqual([REMOTE_TOKEN]); }); @@ -140,6 +163,7 @@ describe("ConnectionCatalogDocument", () => { ); expect(document.targets).toEqual([target]); + expect(document.routes).toEqual([target]); expect(document.profiles).toEqual([profile]); expect(document.credentials).toEqual([]); }); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 0ba55dfa2fbc..2fc28d084a4e 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -1,3 +1,4 @@ +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { @@ -5,7 +6,11 @@ import { ConnectionCredential, ConnectionProfile, } from "../connection/catalog.ts"; -import { type ConnectionTarget, PersistedConnectionTarget } from "../connection/model.ts"; +import { + type ConnectionTarget, + PersistedConnectionTarget, + connectionRouteId, +} from "../connection/model.ts"; import * as TokenStore from "../authorization/tokenStore.ts"; export const StoredConnectionCredential = Schema.Struct({ @@ -17,6 +22,9 @@ export type StoredConnectionCredential = typeof StoredConnectionCredential.Type; export const ConnectionCatalogDocument = Schema.Struct({ schemaVersion: Schema.Literal(1), targets: Schema.Array(PersistedConnectionTarget), + routes: Schema.Array(PersistedConnectionTarget).pipe( + Schema.withDecodingDefaultKey(Effect.succeed([])), + ), profiles: Schema.Array(ConnectionProfile), credentials: Schema.Array(StoredConnectionCredential), remoteDpopTokens: Schema.Array(TokenStore.RemoteDpopAccessToken), @@ -26,6 +34,7 @@ export type ConnectionCatalogDocument = typeof ConnectionCatalogDocument.Type; export const EMPTY_CONNECTION_CATALOG_DOCUMENT: ConnectionCatalogDocument = Object.freeze({ schemaVersion: 1, targets: [], + routes: [], profiles: [], credentials: [], remoteDpopTokens: [], @@ -59,19 +68,21 @@ function connectionIdOf(target: ConnectionTarget): string | null { } } -function removeConnectionMetadata( +export function connectionRoutes( + document: ConnectionCatalogDocument, +): ReadonlyArray { + return document.routes.length === 0 && document.targets.length > 0 + ? document.targets + : document.routes; +} + +function removeRouteMetadata( document: ConnectionCatalogDocument, target: ConnectionTarget, - removeRemoteToken: boolean, ): ConnectionCatalogDocument { const connectionId = connectionIdOf(target); return { ...document, - targets: removeCatalogValue( - document.targets, - (value) => value.environmentId, - target.environmentId, - ), profiles: connectionId === null ? document.profiles @@ -80,13 +91,6 @@ function removeConnectionMetadata( connectionId === null ? document.credentials : removeCatalogValue(document.credentials, (value) => value.connectionId, connectionId), - remoteDpopTokens: removeRemoteToken - ? removeCatalogValue( - document.remoteDpopTokens, - (value) => value.environmentId, - target.environmentId, - ) - : document.remoteDpopTokens, }; } @@ -95,14 +99,15 @@ export function registerConnectionInCatalog( registration: ConnectionRegistration, ): ConnectionCatalogDocument { const target = registration.target; - const previous = document.targets.find( - (candidate) => candidate.environmentId === target.environmentId, + const routes = connectionRoutes(document); + const previous = routes.find( + (candidate) => connectionRouteId(candidate) === connectionRouteId(target), ); - const cleaned = - previous === undefined ? document : removeConnectionMetadata(document, previous, false); + const cleaned = previous === undefined ? document : removeRouteMetadata(document, previous); const next: ConnectionCatalogDocument = { ...cleaned, targets: replaceCatalogValue(cleaned.targets, (value) => value.environmentId, target), + routes: replaceCatalogValue(routes, connectionRouteId, target), }; switch (registration._tag) { @@ -137,5 +142,43 @@ export function removeConnectionFromCatalog( document: ConnectionCatalogDocument, target: ConnectionTarget, ): ConnectionCatalogDocument { - return removeConnectionMetadata(document, target, true); + const environmentRoutes = connectionRoutes(document).filter( + (candidate) => candidate.environmentId === target.environmentId, + ); + const connectionIds = new Set( + environmentRoutes + .map(connectionIdOf) + .filter((connectionId): connectionId is string => connectionId !== null), + ); + return { + ...document, + targets: removeCatalogValue( + document.targets, + (value) => value.environmentId, + target.environmentId, + ), + routes: connectionRoutes(document).filter( + (candidate) => candidate.environmentId !== target.environmentId, + ), + profiles: document.profiles.filter((profile) => !connectionIds.has(profile.connectionId)), + credentials: document.credentials.filter( + (credential) => !connectionIds.has(credential.connectionId), + ), + remoteDpopTokens: removeCatalogValue( + document.remoteDpopTokens, + (value) => value.environmentId, + target.environmentId, + ), + }; +} + +export function selectConnectionRouteInCatalog( + document: ConnectionCatalogDocument, + target: ConnectionCatalogDocument["routes"][number], +): ConnectionCatalogDocument { + return { + ...document, + targets: replaceCatalogValue(document.targets, (value) => value.environmentId, target), + routes: connectionRoutes(document), + }; } diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 6dfa5001a486..c894aa51802d 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -50,6 +50,27 @@ export function createEnvironmentCatalogAtoms( Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), ).pipe(Atom.withLabel("environment-catalog-value")); + const routesAtom = runtime.atom( + Stream.unwrap( + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.map((registry) => SubscriptionRef.changes(registry.routes)), + ), + ), + { + initialValue: new Map< + EnvironmentIdType, + ReadonlyArray + >() as ReadonlyMap>, + }, + ); + + const routesValueAtom = Atom.make((get) => + Option.getOrElse( + AsyncResult.value(get(routesAtom)), + () => new Map>(), + ), + ).pipe(Atom.withLabel("environment-catalog-routes-value")); + const networkStatusAtom = runtime.atom( Stream.unwrap( EnvironmentRegistry.EnvironmentRegistry.pipe( @@ -97,6 +118,15 @@ export function createEnvironmentCatalogAtoms( Effect.flatMap((registry) => registry.remove(environmentId)), ), }); + const selectRoute = createRuntimeCommand(runtime, { + label: "environment-catalog:select-route", + scheduler: commandScheduler, + concurrency: serial, + execute: (input: { readonly environmentId: EnvironmentIdType; readonly routeId: string }) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.selectRoute(input.environmentId, input.routeId)), + ), + }); const removeRelayEnvironments = createRuntimeCommand(runtime, { label: "environment-catalog:remove-relay-environments", scheduler: commandScheduler, @@ -119,10 +149,13 @@ export function createEnvironmentCatalogAtoms( return { catalogAtom, catalogValueAtom, + routesAtom, + routesValueAtom, networkStatusAtom, networkStatusValueAtom, stateAtom, register, + selectRoute, remove, removeRelayEnvironments, retryNow, diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 392885b640c1..13d310e77235 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -188,7 +188,12 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteLaunchScript(), "wait_ready"); assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); - assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.notInclude(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.include(buildRemoteLaunchScript(), "discover_running_runtime()"); + assert.include(buildRemoteLaunchScript(), "systemctl --user show t3code.service"); + assert.include(buildRemoteLaunchScript(), 'fs.readdirSync("/proc")'); + assert.include(buildRemoteLaunchScript(), '"server-runtime.json"'); + assert.include(buildRemoteLaunchScript(), "/.well-known/t3/environment"); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); @@ -197,9 +202,10 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), - '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', + '"$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE"', ); - assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); + assert.include(buildRemotePairingScript(target), 'line.startsWith("Token: ")'); + assert.include(buildRemotePairingScript(target), 'BASE_DIR_FILE="$STATE_DIR/base-dir"'); assert.notInclude(buildRemotePairingScript(target), "server-home"); assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( @@ -236,6 +242,23 @@ describe("ssh tunnel scripts", () => { ); }); + it("embeds syntactically valid Node scripts", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + for (const script of [buildRemoteLaunchScript(), buildRemotePairingScript(target)]) { + const embeddedScripts = [...script.matchAll(/<<'NODE'\n([\s\S]*?)\nNODE/gu)]; + assert.isAbove(embeddedScripts.length, 0); + for (const match of embeddedScripts) { + assert.doesNotThrow(() => Function(match[1]!)); + } + } + }); + it.effect("accepts launch JSON after remote shell startup noise", () => { const target = { alias: "devbox", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 0afd1007f8ed..f50bdc21ace2 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -461,14 +461,30 @@ DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json" PORT_FILE="$STATE_DIR/port" PID_FILE="$STATE_DIR/pid" MANAGED_FILE="$STATE_DIR/managed" +BASE_DIR_FILE="$STATE_DIR/base-dir" LOG_FILE="$STATE_DIR/server.log" RUNNER_FILE="$STATE_DIR/run-t3.sh" RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" mkdir -p "$STATE_DIR" +umask 077 +LAUNCHED_PID="" cleanup_runner_next() { rm -f "$RUNNER_NEXT" } +abort_remote_launch() { + if [ -n "$LAUNCHED_PID" ] && kill -0 "$LAUNCHED_PID" 2>/dev/null; then + kill "$LAUNCHED_PID" 2>/dev/null || true + ABORT_WAIT_COUNT=0 + while kill -0 "$LAUNCHED_PID" 2>/dev/null && [ "$ABORT_WAIT_COUNT" -lt 20 ]; do + ABORT_WAIT_COUNT=$((ABORT_WAIT_COUNT + 1)) + sleep 0.1 + done + fi + rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" "$BASE_DIR_FILE" + exit 1 +} trap cleanup_runner_next EXIT +trap abort_remote_launch HUP INT TERM cat >"$RUNNER_NEXT" <<'SH' @@T3_RUNNER_SCRIPT@@ SH @@ -500,6 +516,87 @@ wait_for_pid_exit() { sleep 0.1 done } +discover_running_runtime() { + rm -f "$BASE_DIR_FILE" + SERVICE_PID="" + if command -v systemctl >/dev/null 2>&1; then + SERVICE_PID="$(systemctl --user show t3code.service --property=MainPID --value 2>/dev/null || true)" + fi + node - "$DEFAULT_SERVER_HOME" "$BASE_DIR_FILE" "$SERVICE_PID" "\${T3CODE_HOME:-}" <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const defaultBaseDir = process.argv[2] ?? ""; +const baseDirOutputPath = process.argv[3] ?? ""; +const servicePid = Number.parseInt(process.argv[4] ?? "", 10); +const environmentBaseDir = process.argv[5] ?? ""; +const candidates = []; +const seen = new Set(); +const addBaseDir = (value) => { + const baseDir = value.trim(); + if (baseDir.length === 0 || seen.has(baseDir)) return; + seen.add(baseDir); + candidates.push(baseDir); +}; +const addBaseDirFromPid = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) return; + try { + const environment = fs.readFileSync(\`/proc/\${pid}/environ\`, "utf8").split("\\0"); + const value = environment.find((entry) => entry.startsWith("T3CODE_HOME=")); + if (value) addBaseDir(value.slice("T3CODE_HOME=".length)); + } catch {} +}; + +addBaseDirFromPid(servicePid); +addBaseDir(environmentBaseDir); +try { + for (const entry of fs.readdirSync("/proc")) { + if (/^[1-9][0-9]*$/u.test(entry)) addBaseDirFromPid(Number(entry)); + } +} catch {} +addBaseDir(defaultBaseDir); + +const isAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error && typeof error === "object" && error.code === "EPERM"; + } +}; + +(async () => { + for (const baseDir of candidates) { + for (const variant of ["userdata", "dev"]) { + try { + const runtime = JSON.parse( + fs.readFileSync(path.join(baseDir, variant, "server-runtime.json"), "utf8"), + ); + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || !isAlive(pid)) { + continue; + } + const response = await fetch( + \`http://127.0.0.1:\${port}/.well-known/t3/environment\`, + { + signal: AbortSignal.timeout(2500), + }, + ); + if (!response.ok) continue; + const descriptor = await response.json(); + if (!descriptor || typeof descriptor.environmentId !== "string") continue; + fs.writeFileSync(baseDirOutputPath, \`\${baseDir}\\n\`, { mode: 0o600 }); + process.stdout.write(String(port)); + return; + } catch {} + } + } + process.exitCode = 1; +})().catch(() => { + process.exitCode = 1; +}); +NODE +} resolve_default_runtime_port() { node - "$DEFAULT_RUNTIME_FILE" <<'NODE' const fs = require("node:fs"); @@ -525,17 +622,23 @@ NODE REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" +RUNNER_RUNTIME_PORT="$(discover_running_runtime 2>/dev/null || true)" DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port 2>/dev/null || true)" DEFAULT_RUNTIME_PID="" DEFAULT_REMOTE_PORT="" -if [ -n "$DEFAULT_RUNTIME_INFO" ]; then +if [ -n "$RUNNER_RUNTIME_PORT" ]; then + DEFAULT_REMOTE_PORT="$RUNNER_RUNTIME_PORT" +elif [ -n "$DEFAULT_RUNTIME_INFO" ]; then DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" fi if [ -n "$DEFAULT_REMOTE_PORT" ]; then + PREVIOUS_REMOTE_PORT="$REMOTE_PORT" REMOTE_PORT="$DEFAULT_REMOTE_PORT" if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then - if [ "$REMOTE_MANAGED" = "managed" ]; then + if [ "$REMOTE_MANAGED" = "managed" ] && [ "$PREVIOUS_REMOTE_PORT" = "$DEFAULT_REMOTE_PORT" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then + printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" + elif [ "$REMOTE_MANAGED" = "managed" ]; then PID_TO_STOP="\${REMOTE_PID:-$DEFAULT_RUNTIME_PID}" if [ -n "$PID_TO_STOP" ] && kill -0 "$PID_TO_STOP" 2>/dev/null; then kill "$PID_TO_STOP" 2>/dev/null || true @@ -590,8 +693,10 @@ 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 "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null & + nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" >>"$LOG_FILE" 2>&1 < /dev/null & REMOTE_PID="$!" + LAUNCHED_PID="$REMOTE_PID" + printf '%s\\n' "\${T3CODE_HOME:-$DEFAULT_SERVER_HOME}" >"$BASE_DIR_FILE" printf '%s\\n' "$REMOTE_PID" >"$PID_FILE" printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" printf 'managed\\n' >"$MANAGED_FILE" @@ -604,24 +709,49 @@ if [ -z "$REMOTE_PORT" ]; then fi kill "$REMOTE_PID" 2>/dev/null || true wait_for_pid_exit "$REMOTE_PID" - rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" + LAUNCHED_PID="" + rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" "$BASE_DIR_FILE" exit 1 fi + LAUNCHED_PID="" fi printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" `; export const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" -DEFAULT_SERVER_HOME="$HOME/.t3" RUNNER_FILE="$STATE_DIR/run-t3.sh" +BASE_DIR_FILE="$STATE_DIR/base-dir" +PAIR_OUTPUT_FILE="$STATE_DIR/pair-output.$$" mkdir -p "$STATE_DIR" +umask 077 +cleanup_pair_output() { + rm -f "$PAIR_OUTPUT_FILE" +} +trap cleanup_pair_output EXIT cat >"$RUNNER_FILE" <<'SH' @@T3_RUNNER_SCRIPT@@ SH chmod 700 "$RUNNER_FILE" -PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" -"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json +PAIRING_BASE_DIR="$(cat "$BASE_DIR_FILE" 2>/dev/null || true)" +if [ -n "$PAIRING_BASE_DIR" ]; then + "$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE" +else + "$RUNNER_FILE" pair >"$PAIR_OUTPUT_FILE" +fi +node - "$PAIR_OUTPUT_FILE" <<'NODE' +const fs = require("node:fs"); +const outputPath = process.argv[2] ?? ""; +try { + const output = fs.readFileSync(outputPath, "utf8"); + const tokenLine = output.split(/\\r?\\n/u).findLast((line) => line.startsWith("Token: ")); + const credential = tokenLine?.slice("Token: ".length).trim() ?? ""; + if (credential.length === 0) process.exit(1); + process.stdout.write(JSON.stringify({ credential }) + "\\n"); +} catch { + process.exit(1); +} +NODE `; export const REMOTE_STOP_SCRIPT = `set -eu @@ -629,6 +759,7 @@ STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" MANAGED_FILE="$STATE_DIR/managed" +BASE_DIR_FILE="$STATE_DIR/base-dir" REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then @@ -639,7 +770,7 @@ if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMO sleep 0.1 done fi -rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" +rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" "$BASE_DIR_FILE" printf '{"stopped":true}\\n' `; From aa25bc2f63a723c6313508ac2ff19dcefe6bd17a Mon Sep 17 00:00:00 2001 From: will Date: Sat, 15 Aug 2026 05:53:11 -0400 Subject: [PATCH 02/26] fix(desktop): make SSH route switching reliable --- apps/web/src/connection/platform.test.ts | 47 ++++- apps/web/src/connection/platform.ts | 61 +++--- docs/internals/remote.md | 15 +- .../client-runtime/src/connection/catalog.ts | 1 + .../src/connection/onboarding.test.ts | 4 + .../src/connection/onboarding.ts | 3 + .../src/connection/registry.test.ts | 7 + .../src/connection/resolver.test.ts | 154 +++++++++++++++ .../client-runtime/src/connection/resolver.ts | 76 +++++--- .../src/platform/capabilities.ts | 1 + .../src/platform/storageDocument.test.ts | 26 +++ .../src/platform/storageDocument.ts | 7 + packages/ssh/src/tunnel.test.ts | 143 +++++++++++++- packages/ssh/src/tunnel.ts | 179 +++++++++++++----- 14 files changed, 612 insertions(+), 112 deletions(-) diff --git a/apps/web/src/connection/platform.test.ts b/apps/web/src/connection/platform.test.ts index b31634705368..08d1be9ab564 100644 --- a/apps/web/src/connection/platform.test.ts +++ b/apps/web/src/connection/platform.test.ts @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect"; import { canRetainCachedPlatformRegistrationAfterRefreshFailure, canReuseCachedPlatformRegistration, + prepareDesktopSshEnvironment, primaryRegistrationToRetainAfterTopologyRead, provisionDesktopSshEnvironment, readPrimaryEnvironmentTargetResult, @@ -28,11 +29,18 @@ const TARGET: DesktopSshEnvironmentTarget = { function makeBridge( calls: string[], - options?: { readonly failDescriptor?: boolean }, + options?: { + readonly failDescriptor?: boolean; + readonly ensurePairingOptions?: Array; + }, ): DesktopBridge { return { - ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => { + ensureSshEnvironment: async ( + target: DesktopSshEnvironmentTarget, + ensureOptions?: { readonly issuePairingToken?: boolean }, + ) => { calls.push("ensure"); + options?.ensurePairingOptions?.push(ensureOptions?.issuePairingToken ?? null); return { target, httpBaseUrl: "http://127.0.0.1:3201/", @@ -95,6 +103,41 @@ describe("desktop SSH pairing", () => { expect(calls).toEqual(["ensure", "descriptor"]); }), ); + + it.effect("reuses a cached bearer without requesting or bootstrapping a pairing token", () => + Effect.gen(function* () { + const calls: string[] = []; + const ensurePairingOptions: Array = []; + + const prepared = yield* prepareDesktopSshEnvironment( + makeBridge(calls, { ensurePairingOptions }), + { + target: TARGET, + bearerToken: "cached-bearer", + }, + ); + + expect(prepared.bearerToken).toBe("cached-bearer"); + expect(calls).toEqual(["ensure"]); + expect(ensurePairingOptions).toEqual([false]); + }), + ); + + it.effect("requests and bootstraps a pairing token when no bearer is cached", () => + Effect.gen(function* () { + const calls: string[] = []; + const ensurePairingOptions: Array = []; + + const prepared = yield* prepareDesktopSshEnvironment( + makeBridge(calls, { ensurePairingOptions }), + { target: TARGET }, + ); + + expect(prepared.bearerToken).toBe("bearer-token"); + expect(calls).toEqual(["ensure", "token"]); + expect(ensurePairingOptions).toEqual([true]); + }), + ); }); describe("desktop-local bearer cache", () => { diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index daead0bc6308..cb82d300835f 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -173,6 +173,44 @@ export const provisionDesktopSshEnvironment = Effect.fn( }; }); +export const prepareDesktopSshEnvironment = Effect.fn("web.connectionPlatform.ssh.prepareDesktop")( + function* ( + bridge: DesktopBridge, + input: { + readonly target: DesktopSshEnvironmentTarget; + readonly bearerToken?: string; + }, + ) { + const bootstrap = yield* Effect.tryPromise({ + try: () => + bridge.ensureSshEnvironment(input.target, { + issuePairingToken: input.bearerToken === undefined, + }), + catch: sshPreparationError, + }); + if (input.bearerToken !== undefined) { + return { + bootstrap, + bearerToken: input.bearerToken, + }; + } + if (bootstrap.pairingToken === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The SSH environment did not issue a pairing credential.", + }); + } + const access = yield* Effect.tryPromise({ + try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), + catch: sshPreparationError, + }); + return { + bootstrap, + bearerToken: access.access_token, + }; + }, +); + const capabilitiesLayer = Layer.effectContext( Effect.sync(() => { const presentation = ClientPresentation.of({ @@ -238,28 +276,7 @@ const capabilitiesLayer = Layer.effectContext( detail: "SSH environments are only available in the desktop app.", }); } - const bootstrap = yield* Effect.tryPromise({ - try: () => - bridge.ensureSshEnvironment(input.target, { - issuePairingToken: true, - }), - catch: sshPreparationError, - }); - if (bootstrap.pairingToken === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "The SSH environment did not issue a pairing credential.", - }); - } - const access = yield* Effect.tryPromise({ - try: () => - bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!), - catch: sshPreparationError, - }); - return { - bootstrap, - bearerToken: access.access_token, - }; + return yield* prepareDesktopSshEnvironment(bridge, input); }), disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) { const bridge = window.desktopBridge; diff --git a/docs/internals/remote.md b/docs/internals/remote.md index da2ba5c438a7..d9301e130a0a 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -169,12 +169,15 @@ returns local HTTP/WS endpoints. Disconnect closes the tunnel and stops the remo launcher started it; a server that was already running (marked `external`) is left running. Remote discovery checks the user-level `t3code.service` process first, then the SSH environment and -same-user `/proc` metadata for `T3CODE_HOME` values. Each recorded runtime is accepted only when its -PID is live and its loopback environment descriptor answers. The discovered base directory is saved -for `t3 pair --base-dir`, so the pairing credential lands in the database the reused server actually -reads. The launcher reuses that reported port before considering a new server. This is what prevents -an SSH access path from creating a second logical environment beside an already-running T3 Connect -service whose private service environment is not inherited by SSH. +same-user `/proc` metadata for `T3CODE_HOME` values. It selects the first authoritative runtime whose +PID is live and whose recorded origin is loopback, then waits for that runtime instead of falling +through to a lower-priority server when it is temporarily busy. Once tunneled, the client fetches the +environment descriptor and verifies the stable environment ID before changing routes. The discovered +base directory is saved for `t3 pair --base-dir`, so a required pairing credential lands in the +database the reused server actually reads. Normal reconnects and route switches reuse the stored +bearer credential rather than writing a new pairing record. This is what prevents an SSH access path +from creating a second logical environment beside an already-running T3 Connect service whose private +service environment is not inherited by SSH. The desktop main process owns this because it can spawn SSH, manage prompts, write launch scripts, and clean up forwards. The renderer connects through the forwarded URL like any other environment and diff --git a/packages/client-runtime/src/connection/catalog.ts b/packages/client-runtime/src/connection/catalog.ts index 8df3b2541d18..19ebdd2eedc2 100644 --- a/packages/client-runtime/src/connection/catalog.ts +++ b/packages/client-runtime/src/connection/catalog.ts @@ -79,6 +79,7 @@ export class SshConnectionRegistration extends Schema.TaggedClass { connectionId: "ssh:environment-ssh", target, }, + credential: { + _tag: "BearerConnectionCredential", + token: "bearer-token", + }, }); }), ); diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index e76bcd50a2cc..0809ac8b8538 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -230,6 +230,9 @@ export const prepareSshRegistration = Effect.fn( label, target: provisioned.bootstrap.target, }), + credential: new BearerConnectionCredential({ + token: provisioned.bearerToken, + }), }); }); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index e278fe611acf..0c38cedeab64 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -227,6 +227,13 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( next.set(registration.profile.connectionId, registration.profile); return next; }); + if (registration.credential !== undefined) { + yield* Ref.update(storedCredentials, (current) => { + const next = new Map(current); + next.set(registration.target.connectionId, registration.credential!); + return next; + }); + } } }), select: (target) => diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d71c0f9602bf..06f55c7caffa 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -23,6 +23,7 @@ import { import * as ConnectionCredentialStore from "./credentialStore.ts"; import { BearerConnectionTarget, + ConnectionBlockedError, ConnectionTransientError, PrimaryConnectionTarget, RelayConnectionTarget, @@ -445,6 +446,159 @@ describe("ConnectionResolver", () => { }), ); + it.effect("reuses a cached SSH bearer without requesting a new pairing credential", () => + Effect.gen(function* () { + const prepareBearerTokens = yield* Ref.make>([]); + const authorizeBearerTokens = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + profiles: [profile], + credentials: [ + [target.connectionId, new BearerConnectionCredential({ token: "cached-ssh" })], + ], + prepareSsh: (input) => + Ref.update(prepareBearerTokens, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + bootstrap: { + target: input.target, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: null, + }, + bearerToken: input.bearerToken ?? "unexpected-fresh-token", + }), + ), + authorizeBearer: (input) => + Ref.update(authorizeBearerTokens, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + environmentId: input.expectedEnvironmentId, + label: "SSH", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "ws://127.0.0.1:4010/ws?wsTicket=cached", + httpAuthorization: { _tag: "Bearer" as const, token: input.bearerToken }, + }), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + yield* broker.prepare(catalogEntry(target, Option.some(profile))); + + expect(yield* Ref.get(prepareBearerTokens)).toEqual(["cached-ssh"]); + expect(yield* Ref.get(authorizeBearerTokens)).toEqual(["cached-ssh"]); + }), + ); + + it.effect("replaces a cached SSH bearer only after authentication rejects it", () => + Effect.gen(function* () { + const prepareBearerTokens = yield* Ref.make>([]); + const authorizeBearerTokens = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + profiles: [profile], + credentials: [ + [target.connectionId, new BearerConnectionCredential({ token: "stale-ssh" })], + ], + prepareSsh: (input) => + Ref.update(prepareBearerTokens, (values) => [...values, input.bearerToken]).pipe( + Effect.as({ + bootstrap: { + target: input.target, + httpBaseUrl: "http://127.0.0.1:4010", + wsBaseUrl: "ws://127.0.0.1:4010", + pairingToken: input.bearerToken === undefined ? "pairing-token" : null, + }, + bearerToken: input.bearerToken ?? "fresh-ssh", + }), + ), + authorizeBearer: (input) => + Ref.update(authorizeBearerTokens, (values) => [...values, input.bearerToken]).pipe( + Effect.flatMap(() => + input.bearerToken === "stale-ssh" + ? Effect.fail( + new ConnectionBlockedError({ + reason: "authentication", + detail: "Credential rejected.", + }), + ) + : Effect.succeed({ + environmentId: input.expectedEnvironmentId, + label: "SSH", + httpBaseUrl: input.httpBaseUrl, + socketUrl: "ws://127.0.0.1:4010/ws?wsTicket=fresh", + httpAuthorization: { _tag: "Bearer" as const, token: input.bearerToken }, + }), + ), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + yield* broker.prepare(catalogEntry(target, Option.some(profile))); + + expect(yield* Ref.get(prepareBearerTokens)).toEqual(["stale-ssh", undefined]); + expect(yield* Ref.get(authorizeBearerTokens)).toEqual(["stale-ssh", "fresh-ssh"]); + }), + ); + + it.effect("does not replace a cached SSH bearer after a transient failure", () => + Effect.gen(function* () { + const prepareBearerTokens = yield* Ref.make>([]); + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: SSH_TARGET, + }); + const brokerLayer = yield* makeDependencies({ + profiles: [profile], + credentials: [ + [target.connectionId, new BearerConnectionCredential({ token: "cached-ssh" })], + ], + prepareSsh: (input) => + Ref.update(prepareBearerTokens, (values) => [...values, input.bearerToken]).pipe( + Effect.flatMap(() => + Effect.fail( + new ConnectionTransientError({ + reason: "timeout", + detail: "SSH endpoint timed out.", + }), + ), + ), + ), + }); + const broker = yield* ConnectionResolver.ConnectionResolver.pipe(Effect.provide(brokerLayer)); + + const error = yield* Effect.flip(broker.prepare(catalogEntry(target, Option.some(profile)))); + + expect(error).toMatchObject({ _tag: "ConnectionTransientError", reason: "timeout" }); + expect(yield* Ref.get(prepareBearerTokens)).toEqual(["cached-ssh"]); + }), + ); + it.effect("classifies relay request timeouts as retryable connection failures", () => Effect.gen(function* () { const target = new RelayConnectionTarget({ diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 3a5d5437a4e6..8e4fc680dac0 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -195,6 +195,7 @@ const makeRelayBroker = Effect.fn("clientRuntime.connection.broker.makeRelay")(f const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(function* () { const profiles = yield* ConnectionProfileStore.ConnectionProfileStore; + const credentials = yield* ConnectionCredentialStore.ConnectionCredentialStore; const ssh = yield* ClientCapabilities.SshEnvironmentGateway; const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; @@ -218,33 +219,56 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct actual: profile.environmentId, }); } - const prepared = yield* ssh.prepare({ - connectionId: target.connectionId, - expectedEnvironmentId: target.environmentId, - target: profile.target, - }); - yield* profiles.put( - new SshConnectionProfile({ - connectionId: profile.connectionId, - environmentId: profile.environmentId, - label: profile.label, - target: prepared.bootstrap.target, - }), + const prepareAndAuthorize = Effect.fn("clientRuntime.connection.broker.ssh.authorize")( + function* (bearerToken?: string) { + const prepared = yield* ssh.prepare({ + connectionId: target.connectionId, + expectedEnvironmentId: target.environmentId, + target: profile.target, + ...(bearerToken === undefined ? {} : { bearerToken }), + }); + yield* profiles.put( + new SshConnectionProfile({ + connectionId: profile.connectionId, + environmentId: profile.environmentId, + label: profile.label, + target: prepared.bootstrap.target, + }), + ); + const authorized = yield* remote.authorizeBearer({ + expectedEnvironmentId: target.environmentId, + httpBaseUrl: prepared.bootstrap.httpBaseUrl, + wsBaseUrl: prepared.bootstrap.wsBaseUrl, + bearerToken: prepared.bearerToken, + }); + if (bearerToken === undefined) { + yield* credentials.put( + target.connectionId, + new BearerConnectionCredential({ token: prepared.bearerToken }), + ); + } + return { + environmentId: authorized.environmentId, + label: authorized.label, + httpBaseUrl: authorized.httpBaseUrl, + socketUrl: authorized.socketUrl, + httpAuthorization: authorized.httpAuthorization, + target, + } satisfies PreparedConnection; + }, ); - const authorized = yield* remote.authorizeBearer({ - expectedEnvironmentId: target.environmentId, - httpBaseUrl: prepared.bootstrap.httpBaseUrl, - wsBaseUrl: prepared.bootstrap.wsBaseUrl, - bearerToken: prepared.bearerToken, - }); - return { - environmentId: authorized.environmentId, - label: authorized.label, - httpBaseUrl: authorized.httpBaseUrl, - socketUrl: authorized.socketUrl, - httpAuthorization: authorized.httpAuthorization, - target, - } satisfies PreparedConnection; + + const credential = yield* credentials.get(target.connectionId); + if (Option.isSome(credential) && isBearerCredential(credential.value)) { + return yield* prepareAndAuthorize(credential.value.token).pipe( + Effect.catch((error) => + error._tag === "ConnectionBlockedError" && error.reason === "authentication" + ? credentials.remove(target.connectionId).pipe(Effect.andThen(prepareAndAuthorize())) + : Effect.fail(error), + ), + ); + } + return yield* prepareAndAuthorize(); }); }); diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index a20b7d404b2f..c1339d1c7e93 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -60,6 +60,7 @@ export class SshEnvironmentGateway extends Context.Service< readonly connectionId: string; readonly expectedEnvironmentId: EnvironmentId; readonly target: DesktopSshEnvironmentTarget; + readonly bearerToken?: string; }) => Effect.Effect; readonly disconnect: ( target: DesktopSshEnvironmentTarget, diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index 7d6f5dd9afb1..f4e759ed3fac 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -167,4 +167,30 @@ describe("ConnectionCatalogDocument", () => { expect(document.profiles).toEqual([profile]); expect(document.credentials).toEqual([]); }); + + it("persists an SSH bearer credential when provisioning supplies one", () => { + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const credential = new BearerConnectionCredential({ token: "ssh-bearer" }); + const document = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new SshConnectionRegistration({ target, profile, credential }), + ); + + expect(document.credentials).toEqual([{ connectionId: target.connectionId, credential }]); + }); }); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 2fc28d084a4e..1bc1f958788b 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -134,6 +134,13 @@ export function registerConnectionInCatalog( (value) => value.connectionId, registration.profile, ), + credentials: + registration.credential === undefined + ? next.credentials + : replaceCatalogValue(next.credentials, (value) => value.connectionId, { + connectionId: registration.target.connectionId, + credential: registration.credential, + }), }; } } diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 13d310e77235..6a32df13b0cf 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -193,7 +193,9 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript(), "systemctl --user show t3code.service"); assert.include(buildRemoteLaunchScript(), 'fs.readdirSync("/proc")'); assert.include(buildRemoteLaunchScript(), '"server-runtime.json"'); - assert.include(buildRemoteLaunchScript(), "/.well-known/t3/environment"); + assert.include(buildRemoteLaunchScript(), 'origin.protocol !== "http:"'); + assert.notInclude(buildRemoteLaunchScript(), "AbortSignal.timeout(2500)"); + assert.include(buildRemoteLaunchScript(), "Existing remote T3 server did not become ready"); assert.notInclude(buildRemoteLaunchScript(), "server-home"); assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); assert.include(buildRemoteLaunchScript(), 'wait_ready "60000"'); @@ -202,16 +204,31 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( buildRemotePairingScript(target), - '"$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE"', + '"$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE" 2>&1', ); + assert.include(buildRemotePairingScript(target), 'if [ -z "$PAIRING_BASE_DIR" ]'); + assert.notInclude(buildRemotePairingScript(target), '"$RUNNER_FILE" pair >'); + assert.include(buildRemotePairingScript(target), "grep -q 'database is locked'"); + assert.include(buildRemotePairingScript(target), 'while [ "$PAIR_ATTEMPT" -lt 5 ]'); + assert.include(buildRemotePairingScript(target), 'RUNNER_NEXT="$STATE_DIR/run-t3.next.$$"'); + assert.include(buildRemotePairingScript(target), 'cat >"$RUNNER_NEXT"'); + assert.include(buildRemotePairingScript(target), 'mv -f "$RUNNER_NEXT" "$RUNNER_FILE"'); + assert.notInclude(buildRemotePairingScript(target), 'cat >"$RUNNER_FILE"'); + for (const script of [ + buildRemoteLaunchScript(), + buildRemotePairingScript(target), + buildRemoteStopScript(target), + ]) { + assert.include(script, "acquire_state_lock()"); + assert.include(script, "release_state_lock()"); + assert.include(script, "flock -w 120 9"); + assert.include(script, "acquire_state_lock"); + } assert.include(buildRemotePairingScript(target), 'line.startsWith("Token: ")'); assert.include(buildRemotePairingScript(target), 'BASE_DIR_FILE="$STATE_DIR/base-dir"'); assert.notInclude(buildRemotePairingScript(target), "server-home"); assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); - assert.include( - buildRemoteStopScript(target), - 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', - ); + assert.include(buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" = "external" ]'); assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include( @@ -259,6 +276,42 @@ describe("ssh tunnel scripts", () => { } }); + it.effect("emits syntactically valid remote shell scripts", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const script of [ + buildRemoteLaunchScript(), + buildRemotePairingScript(target), + buildRemoteStopScript(target), + ]) { + assert.notMatch(script, /@@[A-Z0-9_]+@@/u); + const handle = yield* spawner.spawn(ChildProcess.make("sh", ["-n"])); + yield* Stream.make(new TextEncoder().encode(script)).pipe(Stream.run(handle.stdin)); + const [stderr, exitCode] = yield* Effect.all( + [ + handle.stderr.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (accumulator, chunk) => accumulator + chunk, + ), + ), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ); + assert.equal(exitCode, 0, stderr); + } + }).pipe(Effect.provide(NodeServices.layer)); + }); + it.effect("accepts launch JSON after remote shell startup noise", () => { const target = { alias: "devbox", @@ -469,4 +522,82 @@ describe("ssh tunnel scripts", () => { assert.equal(tunnelKillCount, 1); }).pipe(Effect.provide(layer), Effect.scoped); }); + + it.effect("serializes pairing commands for concurrent ensures of one SSH target", () => { + let activePairingCommands = 0; + let maximumActivePairingCommands = 0; + let pairingCommandCount = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + if (args.includes("-N")) { + return makeRunningProcess(() => {}); + } + if (args.includes("sh") && args.includes("--")) { + return makeSuccessfulProcess('{"remotePort":3773,"serverKind":"external"}\n'); + } + if (args.includes("sh")) { + pairingCommandCount += 1; + const stdout = Stream.make( + new TextEncoder().encode(`{"credential":"PAIR-${pairingCommandCount}"}\n`), + ); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(200 + pairingCommandCount), + stdout, + stderr: Stream.empty, + all: stdout, + exitCode: Effect.gen(function* () { + activePairingCommands += 1; + maximumActivePairingCommands = Math.max( + maximumActivePairingCommands, + activePairingCommands, + ); + yield* Effect.yieldNow; + activePairingCommands -= 1; + return ChildProcessSpawner.ExitCode(0); + }), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + } + return makeSuccessfulProcess("\n"); + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + return Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const results = yield* Effect.all( + [ + manager.ensureEnvironment(target, { issuePairingToken: true }), + manager.ensureEnvironment(target, { issuePairingToken: true }), + ], + { concurrency: "unbounded" }, + ); + + assert.equal(pairingCommandCount, 2); + assert.equal(maximumActivePairingCommands, 1); + assert.deepEqual(results.map((result) => result.pairingToken).toSorted(), [ + "PAIR-1", + "PAIR-2", + ]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index f50bdc21ace2..82f576f898b8 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; @@ -56,7 +57,7 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 60_000; const REMOTE_LAUNCH_TIMEOUT_MS = 90_000; -const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; +const REMOTE_REUSE_READY_TIMEOUT_MS = 20_000; export interface RemoteT3RunnerOptions { readonly packageSpec?: string; @@ -452,6 +453,44 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE exit 1 `; +const REMOTE_STATE_LOCK_SCRIPT = `acquire_state_lock() { + STATE_LOCK_FILE="$STATE_DIR/operation.lock" + STATE_LOCK_DIR="$STATE_DIR/operation.lock.d" + STATE_LOCK_MODE="" + if command -v flock >/dev/null 2>&1; then + exec 9>"$STATE_LOCK_FILE" + if ! flock -w 120 9; then + printf 'Timed out waiting for another T3 SSH operation to finish.\n' >&2 + return 1 + fi + STATE_LOCK_MODE="flock" + return 0 + fi + + STATE_LOCK_WAIT_COUNT=0 + while ! mkdir "$STATE_LOCK_DIR" 2>/dev/null; do + STATE_LOCK_WAIT_COUNT=$((STATE_LOCK_WAIT_COUNT + 1)) + if [ "$STATE_LOCK_WAIT_COUNT" -ge 1200 ]; then + printf 'Timed out waiting for another T3 SSH operation to finish.\n' >&2 + return 1 + fi + sleep 0.1 + done + printf '%s\n' "$$" >"$STATE_LOCK_DIR/pid" + STATE_LOCK_MODE="mkdir" +} + +release_state_lock() { + if [ "\${STATE_LOCK_MODE:-}" = "flock" ]; then + flock -u 9 2>/dev/null || true + exec 9>&- + elif [ "\${STATE_LOCK_MODE:-}" = "mkdir" ]; then + rm -f "$STATE_LOCK_DIR/pid" + rmdir "$STATE_LOCK_DIR" 2>/dev/null || true + fi + STATE_LOCK_MODE="" +}`; + export const REMOTE_LAUNCH_SCRIPT = `set -eu @@T3_NODE_ENV_SCRIPT@@ STATE_KEY="$1" @@ -467,9 +506,11 @@ RUNNER_FILE="$STATE_DIR/run-t3.sh" RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" mkdir -p "$STATE_DIR" umask 077 +@@T3_STATE_LOCK_SCRIPT@@ LAUNCHED_PID="" cleanup_runner_next() { rm -f "$RUNNER_NEXT" + release_state_lock } abort_remote_launch() { if [ -n "$LAUNCHED_PID" ] && kill -0 "$LAUNCHED_PID" 2>/dev/null; then @@ -485,6 +526,7 @@ abort_remote_launch() { } trap cleanup_runner_next EXIT trap abort_remote_launch HUP INT TERM +acquire_state_lock cat >"$RUNNER_NEXT" <<'SH' @@T3_RUNNER_SCRIPT@@ SH @@ -564,37 +606,28 @@ const isAlive = (pid) => { } }; -(async () => { - for (const baseDir of candidates) { - for (const variant of ["userdata", "dev"]) { - try { - const runtime = JSON.parse( - fs.readFileSync(path.join(baseDir, variant, "server-runtime.json"), "utf8"), - ); - const pid = Number(runtime.pid); - const port = Number(runtime.port); - if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || !isAlive(pid)) { - continue; - } - const response = await fetch( - \`http://127.0.0.1:\${port}/.well-known/t3/environment\`, - { - signal: AbortSignal.timeout(2500), - }, - ); - if (!response.ok) continue; - const descriptor = await response.json(); - if (!descriptor || typeof descriptor.environmentId !== "string") continue; - fs.writeFileSync(baseDirOutputPath, \`\${baseDir}\\n\`, { mode: 0o600 }); - process.stdout.write(String(port)); - return; - } catch {} - } +for (const baseDir of candidates) { + for (const variant of ["userdata", "dev"]) { + try { + const runtime = JSON.parse( + fs.readFileSync(path.join(baseDir, variant, "server-runtime.json"), "utf8"), + ); + const pid = Number(runtime.pid); + const port = Number(runtime.port); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port) || !isAlive(pid)) { + continue; + } + const origin = new URL(String(runtime.origin ?? "")); + if (origin.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(origin.hostname)) { + continue; + } + fs.writeFileSync(baseDirOutputPath, \`\${baseDir}\\n\`, { mode: 0o600 }); + process.stdout.write(String(port)); + process.exit(0); + } catch {} } - process.exitCode = 1; -})().catch(() => { - process.exitCode = 1; -}); +} +process.exitCode = 1; NODE } resolve_default_runtime_port() { @@ -657,9 +690,8 @@ if [ -n "$DEFAULT_REMOTE_PORT" ]; then REMOTE_MANAGED="external" fi else - REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" - REMOTE_PORT="$(cat "$PORT_FILE" 2>/dev/null || true)" - REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" + printf 'Existing remote T3 server did not become ready on 127.0.0.1:%s.\n' "$REMOTE_PORT" >&2 + exit 1 fi fi if [ "$REMOTE_MANAGED" = "external" ]; then @@ -721,23 +753,44 @@ printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGE export const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" RUNNER_FILE="$STATE_DIR/run-t3.sh" +RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" BASE_DIR_FILE="$STATE_DIR/base-dir" PAIR_OUTPUT_FILE="$STATE_DIR/pair-output.$$" mkdir -p "$STATE_DIR" umask 077 -cleanup_pair_output() { - rm -f "$PAIR_OUTPUT_FILE" +@@T3_STATE_LOCK_SCRIPT@@ +cleanup_pairing_files() { + rm -f "$PAIR_OUTPUT_FILE" "$RUNNER_NEXT" + release_state_lock } -trap cleanup_pair_output EXIT -cat >"$RUNNER_FILE" <<'SH' +trap cleanup_pairing_files EXIT +acquire_state_lock +cat >"$RUNNER_NEXT" <<'SH' @@T3_RUNNER_SCRIPT@@ SH -chmod 700 "$RUNNER_FILE" +chmod 700 "$RUNNER_NEXT" +mv -f "$RUNNER_NEXT" "$RUNNER_FILE" PAIRING_BASE_DIR="$(cat "$BASE_DIR_FILE" 2>/dev/null || true)" -if [ -n "$PAIRING_BASE_DIR" ]; then - "$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE" -else - "$RUNNER_FILE" pair >"$PAIR_OUTPUT_FILE" +if [ -z "$PAIRING_BASE_DIR" ]; then + printf 'SSH environment state is missing its T3 base directory. Retry the connection.\n' >&2 + exit 1 +fi +PAIR_ATTEMPT=0 +PAIR_SUCCEEDED=0 +while [ "$PAIR_ATTEMPT" -lt 5 ]; do + PAIR_ATTEMPT=$((PAIR_ATTEMPT + 1)) + if "$RUNNER_FILE" pair --base-dir "$PAIRING_BASE_DIR" >"$PAIR_OUTPUT_FILE" 2>&1; then + PAIR_SUCCEEDED=1 + break + fi + if ! grep -q 'database is locked' "$PAIR_OUTPUT_FILE"; then + break + fi + sleep 0.2 +done +if [ "$PAIR_SUCCEEDED" -ne 1 ]; then + printf 'Remote T3 server could not issue an SSH pairing credential.\n' >&2 + exit 1 fi node - "$PAIR_OUTPUT_FILE" <<'NODE' const fs = require("node:fs"); @@ -760,9 +813,21 @@ PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" MANAGED_FILE="$STATE_DIR/managed" BASE_DIR_FILE="$STATE_DIR/base-dir" +mkdir -p "$STATE_DIR" +umask 077 +@@T3_STATE_LOCK_SCRIPT@@ +cleanup_stop() { + release_state_lock +} +trap cleanup_stop EXIT +acquire_state_lock REMOTE_MANAGED="$(cat "$MANAGED_FILE" 2>/dev/null || true)" REMOTE_PID="$(cat "$PID_FILE" 2>/dev/null || true)" -if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then +if [ "$REMOTE_MANAGED" = "external" ]; then + printf '{"stopped":true}\n' + exit 0 +fi +if [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then kill "$REMOTE_PID" 2>/dev/null || true WAIT_COUNT=0 while kill -0 "$REMOTE_PID" 2>/dev/null && [ "$WAIT_COUNT" -lt 20 ]; do @@ -809,6 +874,7 @@ export function buildRemoteLaunchScript(input?: RemoteT3RunnerOptions): string { T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), T3_PICK_PORT_SCRIPT: stripTrailingNewlines(REMOTE_PICK_PORT_SCRIPT), T3_WAIT_READY_SCRIPT: stripTrailingNewlines(REMOTE_WAIT_READY_SCRIPT), + T3_STATE_LOCK_SCRIPT: stripTrailingNewlines(REMOTE_STATE_LOCK_SCRIPT), T3_DEFAULT_REMOTE_PORT: String(DEFAULT_REMOTE_PORT), T3_REMOTE_PORT_SCAN_WINDOW: String(REMOTE_PORT_SCAN_WINDOW), T3_READY_TIMEOUT_MS: String(REMOTE_READY_TIMEOUT_MS), @@ -824,12 +890,14 @@ export function buildRemotePairingScript( return applyScriptPlaceholders(REMOTE_PAIRING_SCRIPT, { T3_STATE_KEY: remoteStateKey(target), T3_RUNNER_SCRIPT: stripTrailingNewlines(buildRemoteT3RunnerScript(input)), + T3_STATE_LOCK_SCRIPT: stripTrailingNewlines(REMOTE_STATE_LOCK_SCRIPT), }); } export function buildRemoteStopScript(target: DesktopSshEnvironmentTarget): string { return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, { T3_STATE_KEY: remoteStateKey(target), + T3_STATE_LOCK_SCRIPT: stripTrailingNewlines(REMOTE_STATE_LOCK_SCRIPT), }); } @@ -1308,6 +1376,15 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma Deferred.Deferred >(); const authSecrets = new Map(); + const pairingSemaphores = new Map(); + + const pairingSemaphoreFor = (key: string) => { + const existing = pairingSemaphores.get(key); + if (existing !== undefined) return existing; + const semaphore = Semaphore.makeUnsafe(1); + pairingSemaphores.set(key, semaphore); + return semaphore; + }; const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( entry: SshTunnelEntry, @@ -1588,7 +1665,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma remotePort: entry.remotePort, }); const readinessExit = yield* Effect.exit( - waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: 2_000 }), + waitForHttpReady({ baseUrl: entry.httpBaseUrl, timeoutMs: SSH_READY_TIMEOUT_MS }), ); if (Exit.isSuccess(readinessExit)) { yield* Effect.logDebug("ssh.environment.tunnel.reused", { @@ -1683,11 +1760,13 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); const pairingResult = requestOptions?.issuePairingToken - ? yield* runWithSshAuth({ - key, - target: entry.target, - operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner), - }) + ? yield* pairingSemaphoreFor(key).withPermit( + runWithSshAuth({ + key, + target: entry.target, + operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner), + }), + ) : null; const pairingToken = pairingResult?.credential ?? null; From 24851cacf630493c6170b55fb3e9fcc02d53f4ff Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 14:08:38 -0400 Subject: [PATCH 03/26] fix(connection): preserve non-relay routes on sign-out --- apps/mobile/src/connection/storage.ts | 8 ++ .../settings/ConnectionsSettings.tsx | 6 -- apps/web/src/connection/storage.ts | 6 ++ .../src/connection/registry.test.ts | 85 +++++++++++++++++++ .../client-runtime/src/connection/registry.ts | 62 ++++++++++++-- .../src/platform/persistence.ts | 5 ++ .../src/platform/storageDocument.test.ts | 29 +++++++ .../src/platform/storageDocument.ts | 31 +++++++ 8 files changed, 218 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index ce370cb25186..fe5489e369d4 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -3,6 +3,7 @@ import { ConnectionRegistrationStore, ConnectionTargetStore, registerConnectionInCatalog, + removeConnectionRouteFromCatalog, removeConnectionFromCatalog, removeCatalogValue, replaceCatalogValue, @@ -27,6 +28,7 @@ function targetPersistenceError( | "list-routes" | "register-connection" | "select-connection-route" + | "remove-connection-route" | "remove-connection", error: ConnectionTransientError, ) { @@ -61,6 +63,12 @@ export const connectionStorageLayer = Layer.effectContext( .pipe( Effect.mapError((error) => targetPersistenceError("select-connection-route", error)), ), + removeRoute: (target, fallback) => + catalog + .update((document) => removeConnectionRouteFromCatalog(document, target, fallback)) + .pipe( + Effect.mapError((error) => targetPersistenceError("remove-connection-route", error)), + ), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 540b1cac612c..47278d21708a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -33,7 +33,6 @@ import { connectionRouteId, connectionStatusText, } from "@t3tools/client-runtime/connection"; -import type { Discovery } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1356,7 +1355,6 @@ function NetworkAccessDescription({ type SavedBackendListRowProps = { environment: EnvironmentPresentation; routes: ReadonlyArray; - relayEnvironment: Discovery.RelayDiscoveredEnvironment | null; removingEnvironmentId: EnvironmentId | null; switchingRouteEnvironmentId: EnvironmentId | null; onConnect: (environmentId: EnvironmentId) => void; @@ -1382,7 +1380,6 @@ function connectionRouteLabel(entry: ConnectionCatalogEntry): string { function SavedBackendListRow({ environment, routes, - relayEnvironment, removingEnvironmentId, switchingRouteEnvironmentId, onConnect, @@ -3539,9 +3536,6 @@ export function ConnectionsSettings() { key={environment.environmentId} environment={environment} routes={desktopBridge ? (environmentRoutes.get(environment.environmentId) ?? []) : []} - relayEnvironment={ - relayEnvironmentDiscoveryState.environments.get(environment.environmentId) ?? null - } removingEnvironmentId={removingSavedEnvironmentId} switchingRouteEnvironmentId={switchingRouteEnvironmentId} onConnect={handleConnectSavedBackend} diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index b12ee3139bff..eee54b0adda3 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -8,6 +8,7 @@ import { EnvironmentCacheStore, registerConnectionInCatalog, removeCatalogValue, + removeConnectionRouteFromCatalog, removeConnectionFromCatalog, replaceCatalogValue, selectConnectionRouteInCatalog, @@ -102,6 +103,7 @@ function persistenceError( | "list-routes" | "register-connection" | "select-connection-route" + | "remove-connection-route" | "remove-connection" | "load-shell" | "save-shell" @@ -393,6 +395,10 @@ export const connectionStorageLayer = Layer.effectContext( catalog .update((document) => selectConnectionRouteInCatalog(document, target)) .pipe(Effect.mapError((cause) => persistenceError("select-connection-route", cause))), + removeRoute: (target, fallback) => + catalog + .update((document) => removeConnectionRouteFromCatalog(document, target, fallback)) + .pipe(Effect.mapError((cause) => persistenceError("remove-connection-route", cause))), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 0c38cedeab64..b4235688e964 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -242,6 +242,32 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( next.set(target.environmentId, target); return next; }), + removeRoute: (target, fallback) => + Effect.gen(function* () { + yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; + yield* Ref.update(storedTargets, (current) => { + const next = new Map(current); + if ( + connectionRouteId(next.get(target.environmentId) ?? fallback) === + connectionRouteId(target) + ) { + next.set(target.environmentId, fallback); + } + return next; + }); + yield* Ref.update(storedRoutes, (current) => { + const next = new Map(current); + next.delete(connectionRouteId(target)); + return next; + }); + if (target._tag === "RelayConnectionTarget") { + yield* Ref.update(storedRemoteTokens, (current) => { + const next = new Map(current); + next.delete(target.environmentId); + return next; + }); + } + }), remove: (target) => Effect.gen(function* () { yield* options?.beforeRegistrationRemove?.(target) ?? Effect.void; @@ -449,6 +475,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( return { layer, storedTargets, + storedRoutes, shellCache, cacheClears, ownedDataClears, @@ -842,6 +869,64 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("removes a remembered relay route without disconnecting the selected SSH route", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [SSH_CONNECTION], + [SSH_PROFILE], + [[SSH_CONNECTION.connectionId, BEARER_CREDENTIAL]], + { initialRoutes: [SSH_RELAY_TARGET, SSH_CONNECTION] }, + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.removeRelayEnvironments(); + + expect((yield* Ref.get(harness.storedTargets)).get(SSH_CONNECTION.environmentId)).toEqual( + SSH_CONNECTION, + ); + expect([...(yield* Ref.get(harness.storedRoutes)).values()]).toEqual([SSH_CONNECTION]); + expect((yield* Ref.get(harness.storedProfiles)).has(SSH_CONNECTION.connectionId)).toBe( + true, + ); + expect((yield* Ref.get(harness.storedCredentials)).has(SSH_CONNECTION.connectionId)).toBe( + true, + ); + expect((yield* Ref.get(harness.storedRemoteTokens)).has(SSH_CONNECTION.environmentId)).toBe( + false, + ); + expect(yield* Ref.get(harness.cacheClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.disconnectedSshTargets)).toEqual([]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("falls back to SSH when signing out while the relay route is selected", () => + Effect.gen(function* () { + const harness = yield* makeHarness( + [SSH_RELAY_TARGET], + [SSH_PROFILE], + [[SSH_CONNECTION.connectionId, BEARER_CREDENTIAL]], + { initialRoutes: [SSH_RELAY_TARGET, SSH_CONNECTION] }, + ); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.removeRelayEnvironments(); + + expect((yield* Ref.get(harness.storedTargets)).get(SSH_CONNECTION.environmentId)).toEqual( + SSH_CONNECTION, + ); + expect( + (yield* SubscriptionRef.get(registry.entries)).get(SSH_CONNECTION.environmentId)?.target, + ).toEqual(SSH_CONNECTION); + expect(yield* Ref.get(harness.cacheClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("keeps the runtime registered when durable removal fails", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET], [], [], { diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 5be46cc5e7c7..11d638071164 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -28,6 +28,8 @@ import type { ConnectionAttemptError, ConnectionTarget, NetworkStatus, + PersistedConnectionTarget, + RelayConnectionTarget, SupervisorConnectionState, } from "./model.ts"; import { connectionRouteId } from "./model.ts"; @@ -712,16 +714,60 @@ export const make = Effect.gen(function* () { const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( function* () { - const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] - .filter((entry) => entry.target._tag === "RelayConnectionTarget") - .map((entry) => entry.target.environmentId); + const relayEnvironments = [...(yield* SubscriptionRef.get(routes))].flatMap( + ([environmentId, environmentRoutes]) => { + const relay = environmentRoutes.find( + (entry): entry is ConnectionCatalogEntry & { readonly target: RelayConnectionTarget } => + entry.target._tag === "RelayConnectionTarget", + ); + if (relay === undefined) return []; + const fallback = environmentRoutes.find( + ( + entry, + ): entry is ConnectionCatalogEntry & { + readonly target: Exclude; + } => + entry.target._tag === "BearerConnectionTarget" || + entry.target._tag === "SshConnectionTarget", + ); + return [{ environmentId, relay, fallback }]; + }, + ); yield* Effect.forEach( - relayEnvironmentIds, - (environmentId) => - remove(environmentId).pipe( - Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), - ), + relayEnvironments, + ({ environmentId, relay, fallback }) => { + if (fallback === undefined) { + return remove(environmentId).pipe( + Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + ); + } + return withLeaseLock( + environmentId, + Effect.gen(function* () { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { + return yield* new PlatformEnvironmentRemovalError({ environmentId }); + } + const selected = yield* getEntry(environmentId); + yield* registrations.removeRoute(relay.target, fallback.target); + yield* SubscriptionRef.update(routes, (current) => { + const next = new Map(current); + const remaining = (next.get(environmentId) ?? []).filter( + (entry) => connectionRouteId(entry.target) !== connectionRouteId(relay.target), + ); + next.set(environmentId, remaining); + return next; + }); + if (selected.target._tag !== "RelayConnectionTarget") return; + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.set(environmentId, fallback.target); + return next; + }); + yield* installEntryLocked(fallback); + }), + ).pipe(Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void)); + }, { concurrency: "unbounded", discard: true, diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 7e59c8db11c3..73d4b5d81e3b 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -22,6 +22,7 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass Effect.Effect; + readonly removeRoute: ( + target: PersistedConnectionTarget, + fallback: PersistedConnectionTarget, + ) => Effect.Effect; readonly remove: (target: ConnectionTarget) => Effect.Effect; } >()("@t3tools/client-runtime/platform/persistence/ConnectionRegistrationStore") {} diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index f4e759ed3fac..a39b1bec2ff4 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -21,6 +21,7 @@ import { ConnectionCatalogDocument, connectionRoutes, registerConnectionInCatalog, + removeConnectionRouteFromCatalog, removeConnectionFromCatalog, } from "./storageDocument.ts"; @@ -140,6 +141,34 @@ describe("ConnectionCatalogDocument", () => { ); }); + it("removes a relay route without discarding another route for the environment", () => { + const relayTarget = new RelayConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "Remote", + }); + const registered = registerConnectionInCatalog( + registerConnectionInCatalog( + { + ...EMPTY_CONNECTION_CATALOG_DOCUMENT, + remoteDpopTokens: [REMOTE_TOKEN], + }, + new BearerConnectionRegistration({ + target: BEARER_TARGET, + profile: BEARER_PROFILE, + credential: BEARER_CREDENTIAL, + }), + ), + new RelayConnectionRegistration({ target: relayTarget }), + ); + + expect(removeConnectionRouteFromCatalog(registered, relayTarget, BEARER_TARGET)).toEqual({ + ...registered, + targets: [BEARER_TARGET], + routes: [BEARER_TARGET], + remoteDpopTokens: [], + }); + }); + it("persists the normalized SSH profile beside its target", () => { const target = new SshConnectionTarget({ environmentId: ENVIRONMENT_ID, diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index 1bc1f958788b..f1b8988eed07 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -179,6 +179,37 @@ export function removeConnectionFromCatalog( }; } +export function removeConnectionRouteFromCatalog( + document: ConnectionCatalogDocument, + target: ConnectionCatalogDocument["routes"][number], + fallback: ConnectionCatalogDocument["routes"][number], +): ConnectionCatalogDocument { + const cleaned = removeRouteMetadata(document, target); + const selected = document.targets.find( + (candidate) => candidate.environmentId === target.environmentId, + ); + return { + ...cleaned, + targets: + selected !== undefined && connectionRouteId(selected) === connectionRouteId(target) + ? replaceCatalogValue(cleaned.targets, (value) => value.environmentId, fallback) + : cleaned.targets, + routes: connectionRoutes(cleaned).filter( + (candidate) => + candidate.environmentId !== target.environmentId || + connectionRouteId(candidate) !== connectionRouteId(target), + ), + remoteDpopTokens: + target._tag === "RelayConnectionTarget" + ? removeCatalogValue( + cleaned.remoteDpopTokens, + (value) => value.environmentId, + target.environmentId, + ) + : cleaned.remoteDpopTokens, + }; +} + export function selectConnectionRouteInCatalog( document: ConnectionCatalogDocument, target: ConnectionCatalogDocument["routes"][number], From d38971747677c733f12d61f14748e50d9b6a691e Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 15:11:40 -0400 Subject: [PATCH 04/26] fix(connection): address multiroute review findings --- .../settings/ConnectionsSettings.tsx | 2 +- .../client-runtime/src/connection/index.ts | 1 + .../src/connection/registry.test.ts | 6 ++++ .../client-runtime/src/connection/registry.ts | 22 ++++++++++--- .../client-runtime/src/connection/resolver.ts | 11 ++++--- .../src/platform/storageDocument.test.ts | 31 +++++++++++++++++++ .../src/platform/storageDocument.ts | 14 ++++++++- packages/ssh/src/tunnel.test.ts | 10 +++++- packages/ssh/src/tunnel.ts | 28 ++++++++++++++--- 9 files changed, 107 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 47278d21708a..d0da4f422ccb 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1513,7 +1513,7 @@ function SavedBackendListRow({ }} > { (yield* SubscriptionRef.get(registry.entries)).get(TARGET.environmentId)?.target, ).toEqual(TARGET); + const routeError = yield* Effect.flip( + registry.selectRoute(TARGET.environmentId, connectionRouteId(TARGET)), + ); + expect(routeError._tag).toBe("PlatformEnvironmentRouteSelectionError"); + expect(routeError.message).toContain("cannot be selected"); + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); expect(error._tag).toBe("PlatformEnvironmentRemovalError"); expect( diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 11d638071164..cca3a0ba79a8 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -62,6 +62,18 @@ export class PlatformEnvironmentRemovalError extends Schema.TaggedErrorClass()( + "PlatformEnvironmentRouteSelectionError", + { + environmentId: EnvironmentId, + routeId: Schema.String, + }, +) { + override get message(): string { + return `Route ${this.routeId} cannot be selected for platform-managed environment ${this.environmentId}.`; + } +} + export class ConnectionRouteNotRegisteredError extends Schema.TaggedErrorClass()( "ConnectionRouteNotRegisteredError", { @@ -97,7 +109,7 @@ export class EnvironmentRegistry extends Context.Service< | ConnectionAttemptError | EnvironmentNotRegisteredError | ConnectionRouteNotRegisteredError - | PlatformEnvironmentRemovalError + | PlatformEnvironmentRouteSelectionError >; readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; readonly reconcilePlatform: ( @@ -474,7 +486,7 @@ export const make = Effect.gen(function* () { environmentId, Effect.gen(function* () { if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) { - return yield* new PlatformEnvironmentRemovalError({ environmentId }); + return yield* new PlatformEnvironmentRouteSelectionError({ environmentId, routeId }); } yield* getEntry(environmentId); const entry = (yield* SubscriptionRef.get(routes)) @@ -484,7 +496,7 @@ export const make = Effect.gen(function* () { return yield* new ConnectionRouteNotRegisteredError({ environmentId, routeId }); } if (entry.target._tag === "PrimaryConnectionTarget") { - return yield* new PlatformEnvironmentRemovalError({ environmentId }); + return yield* new PlatformEnvironmentRouteSelectionError({ environmentId, routeId }); } const selected = (yield* SubscriptionRef.get(entries)).get(environmentId); if (selected !== undefined && Equal.equals(selected, entry)) { @@ -739,7 +751,7 @@ export const make = Effect.gen(function* () { ({ environmentId, relay, fallback }) => { if (fallback === undefined) { return remove(environmentId).pipe( - Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void), + Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }), ); } return withLeaseLock( @@ -766,7 +778,7 @@ export const make = Effect.gen(function* () { }); yield* installEntryLocked(fallback); }), - ).pipe(Effect.catchTag("EnvironmentNotRegisteredError", () => Effect.void)); + ).pipe(Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void })); }, { concurrency: "unbounded", diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index 8e4fc680dac0..76db2da7f313 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -261,11 +261,12 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct const credential = yield* credentials.get(target.connectionId); if (Option.isSome(credential) && isBearerCredential(credential.value)) { return yield* prepareAndAuthorize(credential.value.token).pipe( - Effect.catch((error) => - error._tag === "ConnectionBlockedError" && error.reason === "authentication" - ? credentials.remove(target.connectionId).pipe(Effect.andThen(prepareAndAuthorize())) - : Effect.fail(error), - ), + Effect.catchTags({ + ConnectionBlockedError: (error) => + error.reason === "authentication" + ? credentials.remove(target.connectionId).pipe(Effect.andThen(prepareAndAuthorize())) + : Effect.fail(error), + }), ); } return yield* prepareAndAuthorize(); diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index a39b1bec2ff4..89459aa8a6a9 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -222,4 +222,35 @@ describe("ConnectionCatalogDocument", () => { expect(document.credentials).toEqual([{ connectionId: target.connectionId, credential }]); }); + + it("preserves an SSH bearer credential when re-registration supplies no replacement", () => { + const target = new SshConnectionTarget({ + environmentId: ENVIRONMENT_ID, + label: "SSH", + connectionId: "ssh-1", + }); + const profile = new SshConnectionProfile({ + connectionId: target.connectionId, + environmentId: target.environmentId, + label: target.label, + target: { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }, + }); + const credential = new BearerConnectionCredential({ token: "ssh-bearer" }); + const registered = registerConnectionInCatalog( + EMPTY_CONNECTION_CATALOG_DOCUMENT, + new SshConnectionRegistration({ target, profile, credential }), + ); + + const reRegistered = registerConnectionInCatalog( + registered, + new SshConnectionRegistration({ target, profile }), + ); + + expect(reRegistered.credentials).toEqual([{ connectionId: target.connectionId, credential }]); + }); }); diff --git a/packages/client-runtime/src/platform/storageDocument.ts b/packages/client-runtime/src/platform/storageDocument.ts index f1b8988eed07..b2db59de2cdb 100644 --- a/packages/client-runtime/src/platform/storageDocument.ts +++ b/packages/client-runtime/src/platform/storageDocument.ts @@ -103,6 +103,12 @@ export function registerConnectionInCatalog( const previous = routes.find( (candidate) => connectionRouteId(candidate) === connectionRouteId(target), ); + const previousCredential = + registration._tag === "SshConnectionRegistration" && registration.credential === undefined + ? document.credentials.find( + (value) => value.connectionId === registration.target.connectionId, + ) + : undefined; const cleaned = previous === undefined ? document : removeRouteMetadata(document, previous); const next: ConnectionCatalogDocument = { ...cleaned, @@ -136,7 +142,13 @@ export function registerConnectionInCatalog( ), credentials: registration.credential === undefined - ? next.credentials + ? previousCredential === undefined + ? next.credentials + : replaceCatalogValue( + next.credentials, + (value) => value.connectionId, + previousCredential, + ) : replaceCatalogValue(next.credentials, (value) => value.connectionId, { connectionId: registration.target.connectionId, credential: registration.credential, diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 6a32df13b0cf..9889b378aaa2 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -221,7 +221,9 @@ describe("ssh tunnel scripts", () => { ]) { assert.include(script, "acquire_state_lock()"); assert.include(script, "release_state_lock()"); - assert.include(script, "flock -w 120 9"); + assert.include(script, "flock -w 45 9"); + assert.include(script, 'kill -0 "$STATE_LOCK_OWNER_PID"'); + assert.include(script, 'STATE_LOCK_WAIT_COUNT" -ge 450'); assert.include(script, "acquire_state_lock"); } assert.include(buildRemotePairingScript(target), 'line.startsWith("Token: ")'); @@ -231,6 +233,12 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" = "external" ]'); assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); + assert.include(buildRemoteLaunchScript(), "< /dev/null 9>&- &"); + assert.include(buildRemoteLaunchScript(), 'if [ -n "$LAUNCHED_PID" ]; then'); + assert.notInclude( + buildRemoteLaunchScript(), + 'discover_running_runtime() {\n rm -f "$BASE_DIR_FILE"', + ); assert.include( buildRemoteLaunchScript(), 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 82f576f898b8..38ffc76bc214 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -459,7 +459,7 @@ const REMOTE_STATE_LOCK_SCRIPT = `acquire_state_lock() { STATE_LOCK_MODE="" if command -v flock >/dev/null 2>&1; then exec 9>"$STATE_LOCK_FILE" - if ! flock -w 120 9; then + if ! flock -w 45 9; then printf 'Timed out waiting for another T3 SSH operation to finish.\n' >&2 return 1 fi @@ -468,9 +468,26 @@ const REMOTE_STATE_LOCK_SCRIPT = `acquire_state_lock() { fi STATE_LOCK_WAIT_COUNT=0 + STATE_LOCK_MISSING_PID_COUNT=0 while ! mkdir "$STATE_LOCK_DIR" 2>/dev/null; do + STATE_LOCK_OWNER_PID="$(cat "$STATE_LOCK_DIR/pid" 2>/dev/null || true)" + if [ -n "$STATE_LOCK_OWNER_PID" ]; then + STATE_LOCK_MISSING_PID_COUNT=0 + if ! kill -0 "$STATE_LOCK_OWNER_PID" 2>/dev/null; then + rm -f "$STATE_LOCK_DIR/pid" + rmdir "$STATE_LOCK_DIR" 2>/dev/null || true + continue + fi + else + STATE_LOCK_MISSING_PID_COUNT=$((STATE_LOCK_MISSING_PID_COUNT + 1)) + if [ "$STATE_LOCK_MISSING_PID_COUNT" -ge 10 ]; then + rmdir "$STATE_LOCK_DIR" 2>/dev/null || true + STATE_LOCK_MISSING_PID_COUNT=0 + continue + fi + fi STATE_LOCK_WAIT_COUNT=$((STATE_LOCK_WAIT_COUNT + 1)) - if [ "$STATE_LOCK_WAIT_COUNT" -ge 1200 ]; then + if [ "$STATE_LOCK_WAIT_COUNT" -ge 450 ]; then printf 'Timed out waiting for another T3 SSH operation to finish.\n' >&2 return 1 fi @@ -521,7 +538,9 @@ abort_remote_launch() { sleep 0.1 done fi - rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" "$BASE_DIR_FILE" + if [ -n "$LAUNCHED_PID" ]; then + rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" "$BASE_DIR_FILE" + fi exit 1 } trap cleanup_runner_next EXIT @@ -559,7 +578,6 @@ wait_for_pid_exit() { done } discover_running_runtime() { - rm -f "$BASE_DIR_FILE" SERVICE_PID="" if command -v systemctl >/dev/null 2>&1; then SERVICE_PID="$(systemctl --user show t3code.service --property=MainPID --value 2>/dev/null || true)" @@ -725,7 +743,7 @@ 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" >>"$LOG_FILE" 2>&1 < /dev/null & + nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" >>"$LOG_FILE" 2>&1 < /dev/null 9>&- & REMOTE_PID="$!" LAUNCHED_PID="$REMOTE_PID" printf '%s\\n' "\${T3CODE_HOME:-$DEFAULT_SERVER_HOME}" >"$BASE_DIR_FILE" From 1343019fa9a4dd8f9c4c9e0db026c5344bee6e6a Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 15:47:28 -0400 Subject: [PATCH 05/26] fix(ssh): harden runtime discovery handoff --- packages/ssh/src/tunnel.test.ts | 6 +++- packages/ssh/src/tunnel.ts | 61 +++++++++++++++++---------------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 9889b378aaa2..46bfd0dcda90 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -191,7 +191,8 @@ describe("ssh tunnel scripts", () => { assert.notInclude(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); assert.include(buildRemoteLaunchScript(), "discover_running_runtime()"); assert.include(buildRemoteLaunchScript(), "systemctl --user show t3code.service"); - assert.include(buildRemoteLaunchScript(), 'fs.readdirSync("/proc")'); + assert.notInclude(buildRemoteLaunchScript(), 'fs.readdirSync("/proc")'); + assert.include(buildRemoteLaunchScript(), "knownBaseDirPath"); assert.include(buildRemoteLaunchScript(), '"server-runtime.json"'); assert.include(buildRemoteLaunchScript(), 'origin.protocol !== "http:"'); assert.notInclude(buildRemoteLaunchScript(), "AbortSignal.timeout(2500)"); @@ -222,6 +223,7 @@ describe("ssh tunnel scripts", () => { assert.include(script, "acquire_state_lock()"); assert.include(script, "release_state_lock()"); assert.include(script, "flock -w 45 9"); + assert.include(script, 'ln "$STATE_LOCK_OWNER_FILE" "$STATE_LOCK_LINK"'); assert.include(script, 'kill -0 "$STATE_LOCK_OWNER_PID"'); assert.include(script, 'STATE_LOCK_WAIT_COUNT" -ge 450'); assert.include(script, "acquire_state_lock"); @@ -234,6 +236,8 @@ describe("ssh tunnel scripts", () => { assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include(buildRemoteLaunchScript(), "< /dev/null 9>&- &"); + assert.include(buildRemoteLaunchScript(), 'mv "$DISCOVERED_BASE_DIR_FILE" "$BASE_DIR_FILE"'); + assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$PREVIOUS_REMOTE_PORT"'); assert.include(buildRemoteLaunchScript(), 'if [ -n "$LAUNCHED_PID" ]; then'); assert.notInclude( buildRemoteLaunchScript(), diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 38ffc76bc214..7b0798e23c5e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -455,7 +455,8 @@ exit 1 const REMOTE_STATE_LOCK_SCRIPT = `acquire_state_lock() { STATE_LOCK_FILE="$STATE_DIR/operation.lock" - STATE_LOCK_DIR="$STATE_DIR/operation.lock.d" + STATE_LOCK_LINK="$STATE_DIR/operation.lock.link" + STATE_LOCK_OWNER_FILE="$STATE_DIR/operation.lock.owner.$$" STATE_LOCK_MODE="" if command -v flock >/dev/null 2>&1; then exec 9>"$STATE_LOCK_FILE" @@ -468,43 +469,38 @@ const REMOTE_STATE_LOCK_SCRIPT = `acquire_state_lock() { fi STATE_LOCK_WAIT_COUNT=0 - STATE_LOCK_MISSING_PID_COUNT=0 - while ! mkdir "$STATE_LOCK_DIR" 2>/dev/null; do - STATE_LOCK_OWNER_PID="$(cat "$STATE_LOCK_DIR/pid" 2>/dev/null || true)" + printf '%s\n' "$$" >"$STATE_LOCK_OWNER_FILE" + while ! ln "$STATE_LOCK_OWNER_FILE" "$STATE_LOCK_LINK" 2>/dev/null; do + STATE_LOCK_OWNER_PID="$(cat "$STATE_LOCK_LINK" 2>/dev/null || true)" if [ -n "$STATE_LOCK_OWNER_PID" ]; then - STATE_LOCK_MISSING_PID_COUNT=0 if ! kill -0 "$STATE_LOCK_OWNER_PID" 2>/dev/null; then - rm -f "$STATE_LOCK_DIR/pid" - rmdir "$STATE_LOCK_DIR" 2>/dev/null || true - continue - fi - else - STATE_LOCK_MISSING_PID_COUNT=$((STATE_LOCK_MISSING_PID_COUNT + 1)) - if [ "$STATE_LOCK_MISSING_PID_COUNT" -ge 10 ]; then - rmdir "$STATE_LOCK_DIR" 2>/dev/null || true - STATE_LOCK_MISSING_PID_COUNT=0 + rm -f "$STATE_LOCK_LINK" continue fi fi STATE_LOCK_WAIT_COUNT=$((STATE_LOCK_WAIT_COUNT + 1)) if [ "$STATE_LOCK_WAIT_COUNT" -ge 450 ]; then printf 'Timed out waiting for another T3 SSH operation to finish.\n' >&2 + rm -f "$STATE_LOCK_OWNER_FILE" return 1 fi sleep 0.1 done - printf '%s\n' "$$" >"$STATE_LOCK_DIR/pid" - STATE_LOCK_MODE="mkdir" + rm -f "$STATE_LOCK_OWNER_FILE" + STATE_LOCK_MODE="link" } release_state_lock() { if [ "\${STATE_LOCK_MODE:-}" = "flock" ]; then flock -u 9 2>/dev/null || true exec 9>&- - elif [ "\${STATE_LOCK_MODE:-}" = "mkdir" ]; then - rm -f "$STATE_LOCK_DIR/pid" - rmdir "$STATE_LOCK_DIR" 2>/dev/null || true + elif [ "\${STATE_LOCK_MODE:-}" = "link" ]; then + STATE_LOCK_OWNER_PID="$(cat "$STATE_LOCK_LINK" 2>/dev/null || true)" + if [ "$STATE_LOCK_OWNER_PID" = "$$" ]; then + rm -f "$STATE_LOCK_LINK" + fi fi + rm -f "\${STATE_LOCK_OWNER_FILE:-}" STATE_LOCK_MODE="" }`; @@ -521,12 +517,13 @@ BASE_DIR_FILE="$STATE_DIR/base-dir" LOG_FILE="$STATE_DIR/server.log" RUNNER_FILE="$STATE_DIR/run-t3.sh" RUNNER_NEXT="$STATE_DIR/run-t3.next.$$" +DISCOVERED_BASE_DIR_FILE="$STATE_DIR/base-dir.next.$$" mkdir -p "$STATE_DIR" umask 077 @@T3_STATE_LOCK_SCRIPT@@ LAUNCHED_PID="" cleanup_runner_next() { - rm -f "$RUNNER_NEXT" + rm -f "$RUNNER_NEXT" "$DISCOVERED_BASE_DIR_FILE" release_state_lock } abort_remote_launch() { @@ -582,13 +579,14 @@ discover_running_runtime() { if command -v systemctl >/dev/null 2>&1; then SERVICE_PID="$(systemctl --user show t3code.service --property=MainPID --value 2>/dev/null || true)" fi - node - "$DEFAULT_SERVER_HOME" "$BASE_DIR_FILE" "$SERVICE_PID" "\${T3CODE_HOME:-}" <<'NODE' + node - "$DEFAULT_SERVER_HOME" "$BASE_DIR_FILE" "$DISCOVERED_BASE_DIR_FILE" "$SERVICE_PID" "\${T3CODE_HOME:-}" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const defaultBaseDir = process.argv[2] ?? ""; -const baseDirOutputPath = process.argv[3] ?? ""; -const servicePid = Number.parseInt(process.argv[4] ?? "", 10); -const environmentBaseDir = process.argv[5] ?? ""; +const knownBaseDirPath = process.argv[3] ?? ""; +const baseDirOutputPath = process.argv[4] ?? ""; +const servicePid = Number.parseInt(process.argv[5] ?? "", 10); +const environmentBaseDir = process.argv[6] ?? ""; const candidates = []; const seen = new Set(); const addBaseDir = (value) => { @@ -606,13 +604,11 @@ const addBaseDirFromPid = (pid) => { } catch {} }; -addBaseDirFromPid(servicePid); addBaseDir(environmentBaseDir); try { - for (const entry of fs.readdirSync("/proc")) { - if (/^[1-9][0-9]*$/u.test(entry)) addBaseDirFromPid(Number(entry)); - } + addBaseDir(fs.readFileSync(knownBaseDirPath, "utf8")); } catch {} +addBaseDirFromPid(servicePid); addBaseDir(defaultBaseDir); const isAlive = (pid) => { @@ -682,11 +678,15 @@ if [ -n "$RUNNER_RUNTIME_PORT" ]; then elif [ -n "$DEFAULT_RUNTIME_INFO" ]; then DEFAULT_RUNTIME_PID="\${DEFAULT_RUNTIME_INFO%% *}" DEFAULT_REMOTE_PORT="\${DEFAULT_RUNTIME_INFO#* }" + printf '%s\n' "$DEFAULT_SERVER_HOME" >"$DISCOVERED_BASE_DIR_FILE" fi if [ -n "$DEFAULT_REMOTE_PORT" ]; then PREVIOUS_REMOTE_PORT="$REMOTE_PORT" REMOTE_PORT="$DEFAULT_REMOTE_PORT" if wait_ready "@@T3_REUSE_READY_TIMEOUT_MS@@"; then + if [ -f "$DISCOVERED_BASE_DIR_FILE" ]; then + mv "$DISCOVERED_BASE_DIR_FILE" "$BASE_DIR_FILE" + fi if [ "$REMOTE_MANAGED" = "managed" ] && [ "$PREVIOUS_REMOTE_PORT" = "$DEFAULT_REMOTE_PORT" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMOTE_PID" 2>/dev/null; then printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE" elif [ "$REMOTE_MANAGED" = "managed" ]; then @@ -709,7 +709,10 @@ if [ -n "$DEFAULT_REMOTE_PORT" ]; then fi else printf 'Existing remote T3 server did not become ready on 127.0.0.1:%s.\n' "$REMOTE_PORT" >&2 - exit 1 + REMOTE_PORT="$PREVIOUS_REMOTE_PORT" + DEFAULT_REMOTE_PORT="" + DEFAULT_RUNTIME_PID="" + rm -f "$DISCOVERED_BASE_DIR_FILE" fi fi if [ "$REMOTE_MANAGED" = "external" ]; then From 369d5de45f11e6f76c3110aa9be51dc3f7aa6061 Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 15:54:09 -0400 Subject: [PATCH 06/26] fix(web): keep route picker within narrow rows --- apps/web/src/components/settings/ConnectionsSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d0da4f422ccb..0319756b3090 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1514,7 +1514,7 @@ function SavedBackendListRow({ > Date: Sat, 22 Aug 2026 15:57:57 -0400 Subject: [PATCH 07/26] fix(web): serialize route selection controls --- apps/web/src/components/settings/ConnectionsSettings.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 0319756b3090..cbc5c44183c6 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1517,8 +1517,7 @@ function SavedBackendListRow({ className="w-full min-w-0 sm:w-48" aria-label={`Connection method for ${environment.label}`} disabled={ - switchingRouteEnvironmentId === environmentId || - removingEnvironmentId === environmentId + switchingRouteEnvironmentId !== null || removingEnvironmentId === environmentId } > {connectionRouteLabel(environment.entry)} From d0733d5bc27caafeeda8c60f6089c7c6e4adc711 Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 16:34:39 -0400 Subject: [PATCH 08/26] fix(web): show pending route switches --- apps/web/src/components/settings/ConnectionsSettings.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index cbc5c44183c6..58454fb2c7cd 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1520,7 +1520,11 @@ function SavedBackendListRow({ switchingRouteEnvironmentId !== null || removingEnvironmentId === environmentId } > - {connectionRouteLabel(environment.entry)} + + {switchingRouteEnvironmentId === environmentId + ? "Switching…" + : connectionRouteLabel(environment.entry)} + {routes.map((route) => ( From 236484af8e05e4d58c691ba823bf524b5672a49d Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 16:39:10 -0400 Subject: [PATCH 09/26] fix(web): track pending route switches per environment --- .../settings/ConnectionsSettings.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 58454fb2c7cd..c3bbd75cf116 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1356,7 +1356,7 @@ type SavedBackendListRowProps = { environment: EnvironmentPresentation; routes: ReadonlyArray; removingEnvironmentId: EnvironmentId | null; - switchingRouteEnvironmentId: EnvironmentId | null; + switchingRouteEnvironmentIds: ReadonlySet; onConnect: (environmentId: EnvironmentId) => void; onRemove: (environmentId: EnvironmentId) => void; onSelectRoute: (environmentId: EnvironmentId, routeId: string) => void; @@ -1381,7 +1381,7 @@ function SavedBackendListRow({ environment, routes, removingEnvironmentId, - switchingRouteEnvironmentId, + switchingRouteEnvironmentIds, onConnect, onRemove, onSelectRoute, @@ -1517,11 +1517,12 @@ function SavedBackendListRow({ className="w-full min-w-0 sm:w-48" aria-label={`Connection method for ${environment.label}`} disabled={ - switchingRouteEnvironmentId !== null || removingEnvironmentId === environmentId + switchingRouteEnvironmentIds.has(environmentId) || + removingEnvironmentId === environmentId } > - {switchingRouteEnvironmentId === environmentId + {switchingRouteEnvironmentIds.has(environmentId) ? "Switching…" : connectionRouteLabel(environment.entry)} @@ -1869,8 +1870,9 @@ export function ConnectionsSettings() { return keys; }, [savedEnvironments]); const [sshConnectionError, setSshConnectionError] = useState(null); - const [switchingRouteEnvironmentId, setSwitchingRouteEnvironmentId] = - useState(null); + const [switchingRouteEnvironmentIds, setSwitchingRouteEnvironmentIds] = useState< + ReadonlySet + >(new Set()); const [connectingSshHostAlias, setConnectingSshHostAlias] = useState(null); const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< @@ -2334,10 +2336,14 @@ export function ConnectionsSettings() { const handleSelectSavedBackendRoute = useCallback( async (environmentId: EnvironmentId, routeId: string) => { - setSwitchingRouteEnvironmentId(environmentId); + setSwitchingRouteEnvironmentIds((current) => new Set(current).add(environmentId)); setSavedBackendError(null); const result = await selectEnvironmentRoute({ environmentId, routeId }); - setSwitchingRouteEnvironmentId(null); + setSwitchingRouteEnvironmentIds((current) => { + const next = new Set(current); + next.delete(environmentId); + return next; + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); const message = @@ -3540,7 +3546,7 @@ export function ConnectionsSettings() { environment={environment} routes={desktopBridge ? (environmentRoutes.get(environment.environmentId) ?? []) : []} removingEnvironmentId={removingSavedEnvironmentId} - switchingRouteEnvironmentId={switchingRouteEnvironmentId} + switchingRouteEnvironmentIds={switchingRouteEnvironmentIds} onConnect={handleConnectSavedBackend} onRemove={handleRemoveSavedBackend} onSelectRoute={handleSelectSavedBackendRoute} From f43aaf8c9982497dce09d9ad8a6d12d68ddf2086 Mon Sep 17 00:00:00 2001 From: will Date: Wed, 12 Aug 2026 23:01:09 -0400 Subject: [PATCH 10/26] feat(desktop): add remote port forwarding --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 7 + apps/desktop/src/ipc/channels.ts | 9 + apps/desktop/src/ipc/methods/portForward.ts | 80 +++ apps/desktop/src/main.ts | 2 + .../DesktopPortForwardManager.test.ts | 89 +++ .../portForward/DesktopPortForwardManager.ts | 601 ++++++++++++++++++ apps/desktop/src/preload.ts | 33 + apps/server/src/auth/http.ts | 20 + .../src/environment/ServerEnvironment.ts | 1 + .../src/portForward/TcpForwardBridge.ts | 300 +++++++++ .../portForward/TcpForwardTicketStore.test.ts | 106 +++ .../src/portForward/TcpForwardTicketStore.ts | 120 ++++ apps/server/src/server.ts | 4 + apps/web/src/components/chat/ChatHeader.tsx | 5 + .../DesktopPortForwardAuthorizationBridge.tsx | 42 ++ .../desktop/DesktopPortForwardControl.test.ts | 48 ++ .../desktop/DesktopPortForwardControl.tsx | 273 ++++++++ .../settings/ConnectionsSettings.tsx | 3 + .../settings/DesktopPortForwardsSettings.tsx | 207 ++++++ apps/web/src/components/ui/select.tsx | 4 +- apps/web/src/connection/platform.ts | 8 +- apps/web/src/routes/__root.tsx | 2 + apps/web/test/environmentHttpTest.ts | 1 + docs/internals/desktop-port-forwarding.md | 168 +++++ docs/user/remote-access.md | 23 + .../client-runtime/src/authorization/index.ts | 1 + .../src/authorization/portForward.test.ts | 57 ++ .../src/authorization/portForward.ts | 55 ++ packages/contracts/src/environment.ts | 3 + packages/contracts/src/environmentHttp.ts | 10 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/portForward.ts | 97 +++ 33 files changed, 2381 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/portForward.ts create mode 100644 apps/desktop/src/portForward/DesktopPortForwardManager.test.ts create mode 100644 apps/desktop/src/portForward/DesktopPortForwardManager.ts create mode 100644 apps/server/src/portForward/TcpForwardBridge.ts create mode 100644 apps/server/src/portForward/TcpForwardTicketStore.test.ts create mode 100644 apps/server/src/portForward/TcpForwardTicketStore.ts create mode 100644 apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx create mode 100644 apps/web/src/components/desktop/DesktopPortForwardControl.test.ts create mode 100644 apps/web/src/components/desktop/DesktopPortForwardControl.tsx create mode 100644 apps/web/src/components/settings/DesktopPortForwardsSettings.tsx create mode 100644 docs/internals/desktop-port-forwarding.md create mode 100644 packages/client-runtime/src/authorization/portForward.test.ts create mode 100644 packages/client-runtime/src/authorization/portForward.ts create mode 100644 packages/contracts/src/portForward.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8e8317db7971..874541a2d370 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -45,11 +45,13 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as PortForwardIpc from "./methods/portForward.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); + yield* PortForwardIpc.installPortForwardEventForwarding(); yield* ipc.handleSync(getAppBranding); yield* ipc.handleSync(getSystemLocale); @@ -89,6 +91,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); yield* ipc.handle(probeRemoteEditors); + yield* ipc.handle(PortForwardIpc.createPortForward); + yield* ipc.handle(PortForwardIpc.listPortForwards); + yield* ipc.handle(PortForwardIpc.stopPortForward); + yield* ipc.handle(PortForwardIpc.stopEnvironmentPortForwards); + yield* ipc.handle(PortForwardIpc.resolvePortForwardAuthorization); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index c4ef82ec8cb7..d04240a86198 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -43,6 +43,15 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled" export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const PORT_FORWARD_CREATE_CHANNEL = "desktop:port-forward-create"; +export const PORT_FORWARD_LIST_CHANNEL = "desktop:port-forward-list"; +export const PORT_FORWARD_STOP_CHANNEL = "desktop:port-forward-stop"; +export const PORT_FORWARD_STOP_ENVIRONMENT_CHANNEL = "desktop:port-forward-stop-environment"; +export const PORT_FORWARD_RESOLVE_AUTHORIZATION_CHANNEL = + "desktop:port-forward-resolve-authorization"; +export const PORT_FORWARD_STATE_CHANNEL = "desktop:port-forward-state"; +export const PORT_FORWARD_AUTHORIZATION_REQUEST_CHANNEL = + "desktop:port-forward-authorization-request"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/portForward.ts b/apps/desktop/src/ipc/methods/portForward.ts new file mode 100644 index 000000000000..853660879db7 --- /dev/null +++ b/apps/desktop/src/ipc/methods/portForward.ts @@ -0,0 +1,80 @@ +import { + DesktopPortForwardAuthorizationResolution, + DesktopPortForwardCreateInput, + DesktopPortForwardSnapshot, + DesktopPortForwardStopEnvironmentInput, + DesktopPortForwardStopInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopPortForwardManager from "../../portForward/DesktopPortForwardManager.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const installPortForwardEventForwarding = Effect.fn( + "desktop.ipc.portForward.installEventForwarding", +)(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + yield* manager.subscribeStateChanges((snapshots) => + electronWindow.sendAll(IpcChannels.PORT_FORWARD_STATE_CHANNEL, snapshots), + ); + yield* manager.subscribeAuthorizationRequests((request) => + electronWindow.sendAll(IpcChannels.PORT_FORWARD_AUTHORIZATION_REQUEST_CHANNEL, request), + ); +}); + +export const createPortForward = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PORT_FORWARD_CREATE_CHANNEL, + payload: DesktopPortForwardCreateInput, + result: DesktopPortForwardSnapshot, + handler: Effect.fn("desktop.ipc.portForward.create")(function* (input) { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + return yield* manager.create(input); + }), +}); + +export const listPortForwards = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PORT_FORWARD_LIST_CHANNEL, + payload: Schema.Void, + result: Schema.Array(DesktopPortForwardSnapshot), + handler: Effect.fn("desktop.ipc.portForward.list")(function* () { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + return yield* manager.list; + }), +}); + +export const stopPortForward = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PORT_FORWARD_STOP_CHANNEL, + payload: DesktopPortForwardStopInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.portForward.stop")(function* ({ id }) { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + yield* manager.stop(id); + }), +}); + +export const stopEnvironmentPortForwards = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PORT_FORWARD_STOP_ENVIRONMENT_CHANNEL, + payload: DesktopPortForwardStopEnvironmentInput, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.portForward.stopEnvironment")(function* ({ environmentId }) { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + yield* manager.stopEnvironment(environmentId); + }), +}); + +export const resolvePortForwardAuthorization = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PORT_FORWARD_RESOLVE_AUTHORIZATION_CHANNEL, + payload: DesktopPortForwardAuthorizationResolution, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.portForward.resolveAuthorization")(function* ({ + requestId, + socketUrl, + }) { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + yield* manager.resolveAuthorization(requestId, socketUrl); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14caeed8a9a1..27cef867799b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -63,6 +63,7 @@ import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; import * as DesktopWslServerTree from "./wsl/DesktopWslServerTree.ts"; +import * as DesktopPortForwardManager from "./portForward/DesktopPortForwardManager.ts"; const desktopEnvironmentLayer = Layer.unwrap( Effect.gen(function* () { @@ -188,6 +189,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, + DesktopPortForwardManager.layer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), diff --git a/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts b/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts new file mode 100644 index 000000000000..c3df1c59d19f --- /dev/null +++ b/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts @@ -0,0 +1,89 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { EnvironmentId } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import * as DesktopPortForwardManager from "./DesktopPortForwardManager.ts"; + +it.layer(NodeServices.layer)("DesktopPortForwardManager", (it) => { + it.effect("atomically allocates and stops a desktop loopback listener", () => + Effect.gen(function* () { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + const created = yield* manager.create({ + environmentId: EnvironmentId.make("environment-a"), + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + + expect(created.localHost).toBe("127.0.0.1"); + expect(created.localPort).toBeGreaterThan(0); + expect(yield* manager.list).toEqual([created]); + + yield* manager.stop(created.id); + expect(yield* manager.list).toEqual([]); + }).pipe(Effect.provide(DesktopPortForwardManager.layer)), + ); + + it.effect("stops only forwards owned by the removed environment", () => + Effect.gen(function* () { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + const firstEnvironment = EnvironmentId.make("environment-a"); + const secondEnvironment = EnvironmentId.make("environment-b"); + yield* manager.create({ + environmentId: firstEnvironment, + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + const retained = yield* manager.create({ + environmentId: secondEnvironment, + remoteHost: "127.0.0.1", + remotePort: 3001, + }); + + yield* manager.stopEnvironment(firstEnvironment); + expect(yield* manager.list).toEqual([retained]); + }).pipe(Effect.provide(DesktopPortForwardManager.layer)), + ); + + it.effect("reports an explicit local-port conflict without replacing the owner", () => + Effect.gen(function* () { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + const environmentId = EnvironmentId.make("environment-a"); + const owner = yield* manager.create({ + environmentId, + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + + const conflict = yield* Effect.flip( + manager.create({ + environmentId, + remoteHost: "127.0.0.1", + remotePort: 3001, + localPort: owner.localPort, + }), + ); + expect(conflict._tag).toBe("DesktopPortForwardError"); + expect(yield* manager.list).toEqual([owner]); + }).pipe(Effect.provide(DesktopPortForwardManager.layer)), + ); + + it.effect("chooses another local port when two environments prefer the same port", () => + Effect.gen(function* () { + const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; + const first = yield* manager.create({ + environmentId: EnvironmentId.make("environment-a"), + remoteHost: "127.0.0.1", + remotePort: 42000, + }); + const second = yield* manager.create({ + environmentId: EnvironmentId.make("environment-b"), + remoteHost: "127.0.0.1", + remotePort: first.localPort, + }); + + expect(second.localPort).not.toBe(first.localPort); + expect(yield* manager.list).toHaveLength(2); + }).pipe(Effect.provide(DesktopPortForwardManager.layer)), + ); +}); diff --git a/apps/desktop/src/portForward/DesktopPortForwardManager.ts b/apps/desktop/src/portForward/DesktopPortForwardManager.ts new file mode 100644 index 000000000000..4625e838d689 --- /dev/null +++ b/apps/desktop/src/portForward/DesktopPortForwardManager.ts @@ -0,0 +1,601 @@ +import { + DesktopPortForwardId, + type DesktopPortForwardAuthorizationRequest, + type DesktopPortForwardCreateInput, + type DesktopPortForwardSnapshot, + TCP_PORT_FORWARD_FRAME_ACK, + TCP_PORT_FORWARD_FRAME_CLOSE, + TCP_PORT_FORWARD_FRAME_DATA, + TCP_PORT_FORWARD_FRAME_ERROR, + TCP_PORT_FORWARD_FRAME_WRITE_END, + TCP_PORT_FORWARD_INITIAL_CREDIT, + TCP_PORT_FORWARD_MAX_DATA_SIZE, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as NodeNet from "node:net"; + +const AUTHORIZATION_TIMEOUT = Duration.seconds(15); +const MAX_CONNECTIONS_PER_FORWARD = 32; +const MAX_CONNECTIONS_TOTAL = 128; +const IDLE_TIMEOUT_MS = 5 * 60 * 1_000; +const NEARBY_PORT_SEARCH_DISTANCE = 20; + +type StateListener = (snapshots: ReadonlyArray) => Effect.Effect; +type AuthorizationListener = ( + request: DesktopPortForwardAuthorizationRequest, +) => Effect.Effect; + +interface ManagedForward { + snapshot: DesktopPortForwardSnapshot; + readonly server: NodeNet.Server; + readonly sockets: Set; + readonly webSockets: Set; +} + +export class DesktopPortForwardError extends Schema.TaggedErrorClass()( + "DesktopPortForwardError", + { operation: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Desktop port forward ${this.operation} failed.`; + } +} + +const controlFrame = (kind: number) => Uint8Array.of(kind); + +const dataFrame = (data: Uint8Array) => { + const frame = new Uint8Array(data.byteLength + 1); + frame[0] = TCP_PORT_FORWARD_FRAME_DATA; + frame.set(data, 1); + return frame; +}; + +const ackFrame = (bytes: number) => { + const frame = new Uint8Array(5); + frame[0] = TCP_PORT_FORWARD_FRAME_ACK; + new DataView(frame.buffer).setUint32(1, bytes, false); + return frame; +}; + +const listen = (server: NodeNet.Server, port: number) => + Effect.callback((resume) => { + const onError = (cause: Error) => { + server.off("listening", onListening); + resume(Effect.fail(new DesktopPortForwardError({ operation: "listen", cause }))); + }; + const onListening = () => { + server.off("error", onError); + const address = server.address(); + if (address === null || typeof address === "string") { + resume( + Effect.fail( + new DesktopPortForwardError({ + operation: "resolve-listener-address", + cause: address, + }), + ), + ); + return; + } + resume(Effect.succeed(address.port)); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen({ host: "127.0.0.1", port, exclusive: true }); + return Effect.sync(() => { + server.off("error", onError); + server.off("listening", onListening); + server.close(); + }); + }); + +const automaticLocalPortCandidates = (preferredPort: number): ReadonlyArray => { + const candidates = [preferredPort]; + for (let distance = 1; distance <= NEARBY_PORT_SEARCH_DISTANCE; distance += 1) { + const higher = preferredPort + distance; + const lower = preferredPort - distance; + if (higher <= 65_535) candidates.push(higher); + if (lower >= 1) candidates.push(lower); + } + return candidates; +}; + +const listenAutomatically = Effect.fn("DesktopPortForwardManager.listenAutomatically")(function* ( + server: NodeNet.Server, + preferredPort: number, +) { + for (const candidate of automaticLocalPortCandidates(preferredPort)) { + const attempt = yield* Effect.result(listen(server, candidate)); + if (Result.isSuccess(attempt)) return attempt.success; + + const cause = attempt.failure.cause; + const code = + typeof cause === "object" && + cause !== null && + "code" in cause && + typeof cause.code === "string" + ? cause.code + : null; + if (code === "EACCES") break; + if (code !== "EADDRINUSE") return yield* attempt.failure; + } + return yield* listen(server, 0); +}); + +const openWebSocket = (socketUrl: string) => + Effect.callback((resume) => { + let url: URL; + try { + url = new URL(socketUrl); + } catch (cause) { + resume(Effect.fail(new DesktopPortForwardError({ operation: "validate-ticket-url", cause }))); + return; + } + if ( + (url.protocol !== "ws:" && url.protocol !== "wss:") || + url.pathname !== "/ws/tcp-forward" || + url.username !== "" || + url.password !== "" + ) { + resume( + Effect.fail( + new DesktopPortForwardError({ + operation: "validate-ticket-url", + cause: "The renderer supplied an invalid bridge URL.", + }), + ), + ); + return; + } + const webSocket = new WebSocket(url); + webSocket.binaryType = "arraybuffer"; + const onOpen = () => { + webSocket.removeEventListener("error", onError); + resume(Effect.succeed(webSocket)); + }; + const onError = (cause: Event) => { + webSocket.removeEventListener("open", onOpen); + webSocket.close(); + resume(Effect.fail(new DesktopPortForwardError({ operation: "connect-bridge", cause }))); + }; + webSocket.addEventListener("open", onOpen, { once: true }); + webSocket.addEventListener("error", onError, { once: true }); + return Effect.sync(() => { + webSocket.removeEventListener("open", onOpen); + webSocket.removeEventListener("error", onError); + webSocket.close(); + }); + }); + +const runConnection = (socket: NodeNet.Socket, webSocket: WebSocket): Effect.Effect => + Effect.callback((resume) => { + let credit = TCP_PORT_FORWARD_INITIAL_CREDIT; + let outstanding = 0; + let pending: Uint8Array = new Uint8Array(0); + let socketEnded = false; + let closed = false; + + const send = (frame: Uint8Array) => { + if (webSocket.readyState === WebSocket.OPEN) { + webSocket.send(frame.slice().buffer as ArrayBuffer); + } + }; + const finish = () => { + if (closed) return; + closed = true; + socket.destroy(); + if ( + webSocket.readyState === WebSocket.OPEN || + webSocket.readyState === WebSocket.CONNECTING + ) { + webSocket.close(); + } + resume(Effect.void); + }; + const protocolFailure = () => { + send(controlFrame(TCP_PORT_FORWARD_FRAME_ERROR)); + finish(); + }; + const flushSocketData = () => { + while (credit > 0 && pending.byteLength > 0) { + const size = Math.min(credit, TCP_PORT_FORWARD_MAX_DATA_SIZE, pending.byteLength); + const chunk = pending.subarray(0, size); + pending = pending.subarray(size); + credit -= size; + outstanding += size; + send(dataFrame(chunk)); + } + if (pending.byteLength === 0 && credit > 0) socket.resume(); + else socket.pause(); + }; + const onSocketData = (chunk: Buffer) => { + if (pending.byteLength === 0) { + pending = chunk; + } else { + const combined = new Uint8Array(pending.byteLength + chunk.byteLength); + combined.set(pending); + combined.set(chunk, pending.byteLength); + pending = combined; + } + flushSocketData(); + }; + const onSocketEnd = () => send(controlFrame(TCP_PORT_FORWARD_FRAME_WRITE_END)); + const onSocketError = () => finish(); + const onSocketClose = () => { + send(controlFrame(TCP_PORT_FORWARD_FRAME_CLOSE)); + finish(); + }; + const onWebSocketClose = () => finish(); + const onWebSocketError = () => finish(); + const onWebSocketMessage = (event: MessageEvent) => { + if (!(event.data instanceof ArrayBuffer)) { + protocolFailure(); + return; + } + const frame = new Uint8Array(event.data); + switch (frame[0]) { + case TCP_PORT_FORWARD_FRAME_DATA: { + const payload = frame.subarray(1); + if ( + payload.byteLength === 0 || + payload.byteLength > TCP_PORT_FORWARD_MAX_DATA_SIZE || + socketEnded + ) { + protocolFailure(); + return; + } + socket.write(payload, (error) => { + if (error) finish(); + else send(ackFrame(payload.byteLength)); + }); + return; + } + case TCP_PORT_FORWARD_FRAME_ACK: { + if (frame.byteLength !== 5) { + protocolFailure(); + return; + } + const bytes = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32( + 1, + false, + ); + if (bytes === 0 || bytes > outstanding) { + protocolFailure(); + return; + } + outstanding -= bytes; + credit += bytes; + flushSocketData(); + return; + } + case TCP_PORT_FORWARD_FRAME_WRITE_END: + if (frame.byteLength !== 1 || socketEnded) { + protocolFailure(); + return; + } + socketEnded = true; + socket.end(); + return; + case TCP_PORT_FORWARD_FRAME_CLOSE: + if (frame.byteLength !== 1) { + protocolFailure(); + return; + } + finish(); + return; + case TCP_PORT_FORWARD_FRAME_ERROR: + if (frame.byteLength > 513) { + protocolFailure(); + return; + } + finish(); + return; + default: + protocolFailure(); + } + }; + + socket.on("data", onSocketData); + socket.once("end", onSocketEnd); + socket.once("error", onSocketError); + socket.once("close", onSocketClose); + webSocket.addEventListener("message", onWebSocketMessage); + webSocket.addEventListener("close", onWebSocketClose, { once: true }); + webSocket.addEventListener("error", onWebSocketError, { once: true }); + socket.setTimeout(IDLE_TIMEOUT_MS, finish); + + return Effect.sync(() => { + socket.off("data", onSocketData); + socket.off("end", onSocketEnd); + socket.off("error", onSocketError); + socket.off("close", onSocketClose); + webSocket.removeEventListener("message", onWebSocketMessage); + webSocket.removeEventListener("close", onWebSocketClose); + webSocket.removeEventListener("error", onWebSocketError); + socket.destroy(); + webSocket.close(); + }); + }); + +export class DesktopPortForwardManager extends Context.Service< + DesktopPortForwardManager, + { + readonly create: ( + input: DesktopPortForwardCreateInput, + ) => Effect.Effect; + readonly list: Effect.Effect>; + readonly stop: (id: DesktopPortForwardId) => Effect.Effect; + readonly stopEnvironment: ( + environmentId: DesktopPortForwardSnapshot["environmentId"], + ) => Effect.Effect; + readonly resolveAuthorization: ( + requestId: string, + socketUrl: string | null, + ) => Effect.Effect; + readonly subscribeStateChanges: ( + listener: StateListener, + ) => Effect.Effect; + readonly subscribeAuthorizationRequests: ( + listener: AuthorizationListener, + ) => Effect.Effect; + } +>()("@t3tools/desktop/portForward/DesktopPortForwardManager") {} + +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const forwards = yield* Ref.make(new Map()); + const pendingAuthorizations = yield* Ref.make( + new Map>(), + ); + const stateListeners = yield* Ref.make(new Set()); + const authorizationListeners = yield* Ref.make(new Set()); + + const snapshots = Ref.get(forwards).pipe( + Effect.map((current) => + [...current.values()] + .map((forward) => forward.snapshot) + .toSorted((a, b) => a.localPort - b.localPort), + ), + ); + const publishState = Effect.flatMap(snapshots, (next) => + Ref.get(stateListeners).pipe( + Effect.flatMap((listeners) => Effect.forEach(listeners, (listener) => listener(next))), + Effect.asVoid, + ), + ); + const updateSnapshot = ( + id: DesktopPortForwardId, + update: (snapshot: DesktopPortForwardSnapshot) => DesktopPortForwardSnapshot, + ) => + Ref.update(forwards, (current) => { + const forward = current.get(id); + if (forward === undefined) return current; + forward.snapshot = update(forward.snapshot); + return new Map(current); + }).pipe(Effect.andThen(publishState)); + + const authorize = Effect.fn("DesktopPortForwardManager.authorize")(function* ( + forward: ManagedForward, + ) { + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new DesktopPortForwardError({ operation: "authorize", cause })), + ); + const deferred = yield* Deferred.make(); + yield* Ref.update(pendingAuthorizations, (current) => + new Map(current).set(requestId, deferred), + ); + const listeners = yield* Ref.get(authorizationListeners); + const request: DesktopPortForwardAuthorizationRequest = { + requestId, + forwardId: forward.snapshot.id, + environmentId: forward.snapshot.environmentId, + remoteHost: forward.snapshot.remoteHost, + remotePort: forward.snapshot.remotePort, + }; + yield* Effect.forEach(listeners, (listener) => listener(request), { discard: true }); + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOption(AUTHORIZATION_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new DesktopPortForwardError({ + operation: "authorize", + cause: "Timed out waiting for renderer authorization.", + }), + ), + onSome: Effect.succeed, + }), + ), + Effect.ensuring( + Ref.update(pendingAuthorizations, (current) => { + const next = new Map(current); + next.delete(requestId); + return next; + }), + ), + ); + }); + + const handleConnection = (forward: ManagedForward, socket: NodeNet.Socket) => + Effect.gen(function* () { + const accepted = yield* Ref.modify(forwards, (current) => { + const activeTotal = [...current.values()].reduce( + (total, entry) => total + entry.sockets.size, + 0, + ); + if ( + !current.has(forward.snapshot.id) || + forward.sockets.size >= MAX_CONNECTIONS_PER_FORWARD || + activeTotal >= MAX_CONNECTIONS_TOTAL + ) { + return [false, current] as const; + } + forward.sockets.add(socket); + return [true, new Map(current)] as const; + }); + if (!accepted) { + socket.destroy(); + return; + } + yield* updateSnapshot(forward.snapshot.id, (snapshot) => ({ + ...snapshot, + activeConnections: snapshot.activeConnections + 1, + lastError: null, + })); + const socketUrl = yield* authorize(forward); + const webSocket = yield* openWebSocket(socketUrl); + forward.webSockets.add(webSocket); + yield* runConnection(socket, webSocket); + forward.webSockets.delete(webSocket); + }).pipe( + Effect.catch((error) => + updateSnapshot(forward.snapshot.id, (snapshot) => ({ + ...snapshot, + lastError: error.message, + })), + ), + Effect.ensuring( + Effect.gen(function* () { + forward.sockets.delete(socket); + socket.destroy(); + yield* updateSnapshot(forward.snapshot.id, (snapshot) => ({ + ...snapshot, + activeConnections: Math.max(0, snapshot.activeConnections - 1), + })); + }), + ), + ); + + const create: DesktopPortForwardManager["Service"]["create"] = Effect.fn( + "DesktopPortForwardManager.create", + )(function* (input) { + const id = DesktopPortForwardId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new DesktopPortForwardError({ operation: "create", cause })), + ), + ); + let managed: ManagedForward | undefined; + const server = NodeNet.createServer({ allowHalfOpen: true }, (socket) => { + if (managed !== undefined) runFork(handleConnection(managed, socket)); + else socket.destroy(); + }); + const localPort = + input.localPort === undefined + ? yield* listenAutomatically(server, input.remotePort) + : yield* listen(server, input.localPort); + const snapshot: DesktopPortForwardSnapshot = { + id, + environmentId: input.environmentId, + localHost: "127.0.0.1", + localPort, + remoteHost: input.remoteHost, + remotePort: input.remotePort, + status: "running", + activeConnections: 0, + lastError: null, + }; + managed = { snapshot, server, sockets: new Set(), webSockets: new Set() }; + yield* Ref.update(forwards, (current) => new Map(current).set(id, managed!)); + yield* publishState; + return snapshot; + }); + + const stopManaged = (managed: ManagedForward) => + Effect.sync(() => { + managed.server.close(); + for (const socket of managed.sockets) socket.destroy(); + for (const webSocket of managed.webSockets) webSocket.close(); + }); + + const stop: DesktopPortForwardManager["Service"]["stop"] = (id) => + Ref.modify(forwards, (current) => { + const next = new Map(current); + const managed = next.get(id); + next.delete(id); + return [Option.fromUndefinedOr(managed), next] as const; + }).pipe( + Effect.flatMap(Option.match({ onNone: () => Effect.void, onSome: stopManaged })), + Effect.andThen(publishState), + ); + + const stopEnvironment: DesktopPortForwardManager["Service"]["stopEnvironment"] = ( + environmentId, + ) => + Effect.gen(function* () { + const current = yield* Ref.get(forwards); + yield* Effect.forEach( + current.values(), + (managed) => + managed.snapshot.environmentId === environmentId + ? stop(managed.snapshot.id) + : Effect.void, + { discard: true }, + ); + }); + + const resolveAuthorization: DesktopPortForwardManager["Service"]["resolveAuthorization"] = ( + requestId, + socketUrl, + ) => + Effect.gen(function* () { + const current = yield* Ref.get(pendingAuthorizations); + const deferred = current.get(requestId); + if (deferred === undefined) return; + if (socketUrl === null) { + yield* Deferred.fail( + deferred, + new DesktopPortForwardError({ + operation: "authorize", + cause: "The environment could not authorize this connection.", + }), + ); + } else { + yield* Deferred.succeed(deferred, socketUrl); + } + }); + + const subscribe = ( + ref: Ref.Ref Effect.Effect>>, + listener: (value: A) => Effect.Effect, + ) => + Effect.acquireRelease( + Ref.update(ref, (current) => new Set(current).add(listener)), + () => + Ref.update(ref, (current) => { + const next = new Set(current); + next.delete(listener); + return next; + }), + ); + + yield* Effect.addFinalizer(() => + Ref.get(forwards).pipe( + Effect.flatMap((current) => Effect.forEach(current.values(), stopManaged, { discard: true })), + ), + ); + + return DesktopPortForwardManager.of({ + create, + list: snapshots, + stop, + stopEnvironment, + resolveAuthorization, + subscribeStateChanges: (listener) => subscribe(stateListeners, listener), + subscribeAuthorizationRequests: (listener) => subscribe(authorizationListeners, listener), + }); +}); + +export const layer = Layer.effect(DesktopPortForwardManager, make); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 407c7c3ef498..0c6a2b54daef 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -112,6 +112,39 @@ contextBridge.exposeInMainWorld("desktopBridge", { }), openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + portForward: { + create: (input) => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_CREATE_CHANNEL, input), + list: () => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_LIST_CHANNEL), + stop: (id) => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_STOP_CHANNEL, { id }), + stopEnvironment: (environmentId) => + ipcRenderer.invoke(IpcChannels.PORT_FORWARD_STOP_ENVIRONMENT_CHANNEL, { environmentId }), + resolveAuthorization: (requestId, socketUrl) => + ipcRenderer.invoke(IpcChannels.PORT_FORWARD_RESOLVE_AUTHORIZATION_CHANNEL, { + requestId, + socketUrl, + }), + onStateChange: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, snapshots: unknown) => { + if (!Array.isArray(snapshots)) return; + listener(snapshots as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.PORT_FORWARD_STATE_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PORT_FORWARD_STATE_CHANNEL, wrappedListener); + }, + onAuthorizationRequest: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, request: unknown) => { + if (typeof request !== "object" || request === null) return; + listener(request as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.PORT_FORWARD_AUTHORIZATION_REQUEST_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener( + IpcChannels.PORT_FORWARD_AUTHORIZATION_REQUEST_CHANNEL, + wrappedListener, + ); + }, + }, onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde251..f1d8fb1bc790 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -36,6 +36,7 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import * as TcpForwardTicketStore from "../portForward/TcpForwardTicketStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const tcpForwardTickets = yield* TcpForwardTicketStore.TcpForwardTicketStore; return handlers .handle( @@ -331,6 +333,24 @@ export const authHttpApiLayer = HttpApiBuilder.group( ), ), ) + .handle( + "tcpPortForwardTicket", + Effect.fn("environment.auth.tcpPortForwardTicket")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const session = yield* requireEnvironmentScope(AuthTerminalOperateScope); + yield* appendCredentialResponseHeaders; + return yield* tcpForwardTickets.issue({ + sessionId: session.sessionId, + remoteHost: args.payload.remoteHost, + remotePort: args.payload.remotePort, + }); + }, + Effect.catchTag("TcpForwardTicketIssueError", (error) => + failEnvironmentInternal("tcp_forward_ticket_issuance_failed", error), + ), + ), + ) .handle( "pairingCredential", Effect.fn("environment.auth.pairingCredential")( diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..4b4c1077c473 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + tcpPortForwarding: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/portForward/TcpForwardBridge.ts b/apps/server/src/portForward/TcpForwardBridge.ts new file mode 100644 index 000000000000..a8dc311894fe --- /dev/null +++ b/apps/server/src/portForward/TcpForwardBridge.ts @@ -0,0 +1,300 @@ +import { + TCP_PORT_FORWARD_FRAME_ACK, + TCP_PORT_FORWARD_FRAME_CLOSE, + TCP_PORT_FORWARD_FRAME_DATA, + TCP_PORT_FORWARD_FRAME_ERROR, + TCP_PORT_FORWARD_FRAME_WRITE_END, + TCP_PORT_FORWARD_INITIAL_CREDIT, + TCP_PORT_FORWARD_MAX_DATA_SIZE, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/unstable/http"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as NodeNet from "node:net"; + +import { failEnvironmentAuthInvalid } from "../auth/http.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import * as TcpForwardTicketStore from "./TcpForwardTicketStore.ts"; + +const MAX_CONCURRENT_FORWARD_CONNECTIONS = 128; +const IDLE_TIMEOUT_MS = 5 * 60 * 1_000; + +export class TcpForwardTargetConnectError extends Schema.TaggedErrorClass()( + "TcpForwardTargetConnectError", + { cause: Schema.Defect() }, +) {} + +const controlFrame = (kind: number) => Uint8Array.of(kind); + +const dataFrame = (data: Uint8Array) => { + const frame = new Uint8Array(data.byteLength + 1); + frame[0] = TCP_PORT_FORWARD_FRAME_DATA; + frame.set(data, 1); + return frame; +}; + +const ackFrame = (bytes: number) => { + const frame = new Uint8Array(5); + frame[0] = TCP_PORT_FORWARD_FRAME_ACK; + new DataView(frame.buffer).setUint32(1, bytes, false); + return frame; +}; + +const errorFrame = (message: string) => { + const encoded = new TextEncoder().encode(message).slice(0, 512); + const frame = new Uint8Array(encoded.byteLength + 1); + frame[0] = TCP_PORT_FORWARD_FRAME_ERROR; + frame.set(encoded, 1); + return frame; +}; + +const connectTarget = (host: string, port: number) => + Effect.callback((resume) => { + const target = NodeNet.createConnection({ host, port }); + const onConnect = () => { + target.off("error", onError); + resume(Effect.succeed(target)); + }; + const onError = (cause: Error) => { + target.off("connect", onConnect); + target.destroy(); + resume(Effect.fail(new TcpForwardTargetConnectError({ cause }))); + }; + target.once("connect", onConnect); + target.once("error", onError); + return Effect.sync(() => { + target.off("connect", onConnect); + target.off("error", onError); + target.destroy(); + }); + }); + +const runBridge = Effect.fn("TcpForwardBridge.run")(function* ( + webSocket: Socket.Socket, + target: NodeNet.Socket, +) { + const writer = yield* webSocket.writer; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + const closed = yield* Deferred.make(); + let credit = TCP_PORT_FORWARD_INITIAL_CREDIT; + let outstanding = 0; + let pending: Uint8Array = new Uint8Array(0); + let targetEnded = false; + let closedOnce = false; + + const writeWebSocket = (frame: Uint8Array | Socket.CloseEvent) => { + runFork(writer(frame).pipe(Effect.catch(() => Effect.void))); + }; + const close = () => { + if (closedOnce) return; + closedOnce = true; + runFork(Deferred.succeed(closed, undefined)); + }; + const flushTargetData = () => { + while (credit > 0 && pending.byteLength > 0) { + const size = Math.min(credit, TCP_PORT_FORWARD_MAX_DATA_SIZE, pending.byteLength); + const chunk = pending.subarray(0, size); + pending = pending.subarray(size); + credit -= size; + outstanding += size; + writeWebSocket(dataFrame(chunk)); + } + if (pending.byteLength === 0 && credit > 0) { + target.resume(); + } else { + target.pause(); + } + }; + const onData = (chunk: Buffer) => { + if (pending.byteLength === 0) { + pending = chunk; + } else { + const combined = new Uint8Array(pending.byteLength + chunk.byteLength); + combined.set(pending); + combined.set(chunk, pending.byteLength); + pending = combined; + } + flushTargetData(); + }; + const onEnd = () => { + writeWebSocket(controlFrame(TCP_PORT_FORWARD_FRAME_WRITE_END)); + }; + const onError = (cause: Error) => { + writeWebSocket(errorFrame(cause.message)); + target.destroy(); + }; + const onClose = () => { + writeWebSocket(controlFrame(TCP_PORT_FORWARD_FRAME_CLOSE)); + writeWebSocket(new Socket.CloseEvent(1000)); + close(); + }; + + target.on("data", onData); + target.once("end", onEnd); + target.once("error", onError); + target.once("close", onClose); + target.setTimeout(IDLE_TIMEOUT_MS, () => target.destroy()); + + const protocolFailure = (reason: string) => { + writeWebSocket(errorFrame(reason)); + writeWebSocket(new Socket.CloseEvent(1002, "invalid tcp forward frame")); + target.destroy(); + }; + + const readWebSocket = webSocket.run((frame) => { + const kind = frame[0]; + switch (kind) { + case TCP_PORT_FORWARD_FRAME_DATA: { + const payload = frame.subarray(1); + if (payload.byteLength === 0 || payload.byteLength > TCP_PORT_FORWARD_MAX_DATA_SIZE) { + protocolFailure("invalid data frame"); + return; + } + if (targetEnded || target.destroyed) { + protocolFailure("data received after write end"); + return; + } + target.write(payload, (error) => { + if (error) onError(error); + else writeWebSocket(ackFrame(payload.byteLength)); + }); + return; + } + case TCP_PORT_FORWARD_FRAME_ACK: { + if (frame.byteLength !== 5) { + protocolFailure("invalid acknowledgement frame"); + return; + } + const bytes = new DataView(frame.buffer, frame.byteOffset, frame.byteLength).getUint32( + 1, + false, + ); + if (bytes === 0 || bytes > outstanding) { + protocolFailure("invalid acknowledgement credit"); + return; + } + outstanding -= bytes; + credit += bytes; + flushTargetData(); + return; + } + case TCP_PORT_FORWARD_FRAME_WRITE_END: + if (frame.byteLength !== 1 || targetEnded) { + protocolFailure("invalid write-end frame"); + return; + } + targetEnded = true; + target.end(); + return; + case TCP_PORT_FORWARD_FRAME_CLOSE: + if (frame.byteLength !== 1) { + protocolFailure("invalid close frame"); + return; + } + target.destroy(); + return; + case TCP_PORT_FORWARD_FRAME_ERROR: + if (frame.byteLength > 513) { + protocolFailure("invalid error frame"); + return; + } + target.destroy(); + return; + default: + protocolFailure("unknown frame type"); + } + }); + + yield* Effect.raceFirst(readWebSocket, Deferred.await(closed)).pipe( + Effect.ensuring( + Effect.sync(() => { + target.off("data", onData); + target.off("end", onEnd); + target.off("error", onError); + target.off("close", onClose); + target.destroy(); + }), + ), + ); +}); + +export const tcpPortForwardWebSocketRouteLayer = Layer.unwrap( + Effect.gen(function* () { + const tickets = yield* TcpForwardTicketStore.TcpForwardTicketStore; + const sessions = yield* SessionStore.SessionStore; + const capacity = yield* Semaphore.make(MAX_CONCURRENT_FORWARD_CONNECTIONS); + return HttpRouter.add( + "GET", + "/ws/tcp-forward", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + const ticket = Option.isSome(url) ? url.value.searchParams.get("ticket") : null; + if (ticket === null || ticket.trim() === "") { + return yield* failEnvironmentAuthInvalid("missing_credential"); + } + const authorization = yield* tickets + .consume(ticket) + .pipe( + Effect.catchTag("TcpForwardTicketInvalidError", () => + failEnvironmentAuthInvalid("invalid_credential"), + ), + ); + return yield* capacity.withPermit( + Effect.gen(function* () { + const target = yield* connectTarget(authorization.remoteHost, authorization.remotePort); + const webSocket = yield* request.upgrade; + const revoked = sessions.streamChanges.pipe( + Stream.filter( + (change) => + change.type === "clientRemoved" && + change.sessionId === authorization.session.sessionId, + ), + Stream.runHead, + Effect.asVoid, + ); + const expiresAt = authorization.session.expiresAt; + const expired = + expiresAt === undefined + ? Effect.never + : DateTime.now.pipe( + Effect.flatMap((now) => + Effect.sleep( + Duration.millis( + Math.max(0, expiresAt.epochMilliseconds - now.epochMilliseconds), + ), + ), + ), + ); + yield* Effect.raceFirst( + runBridge(webSocket, target), + Effect.raceFirst(revoked, expired), + ); + return HttpServerResponse.empty(); + }), + ); + }).pipe( + Effect.catchTag("TcpForwardTargetConnectError", () => + Effect.succeed(HttpServerResponse.text("TCP target unavailable", { status: 502 })), + ), + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + }), + ), + ); + }), +); diff --git a/apps/server/src/portForward/TcpForwardTicketStore.test.ts b/apps/server/src/portForward/TcpForwardTicketStore.test.ts new file mode 100644 index 000000000000..31e6d296bb82 --- /dev/null +++ b/apps/server/src/portForward/TcpForwardTicketStore.test.ts @@ -0,0 +1,106 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { AuthSessionId } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; +import * as TcpForwardTicketStore from "./TcpForwardTicketStore.ts"; + +const sessionStoreLayer = SessionStore.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-tcp-forward-ticket-test-" })), +); + +const testLayer = TcpForwardTicketStore.layer.pipe(Layer.provideMerge(sessionStoreLayer)); + +it.layer(NodeServices.layer)("TcpForwardTicketStore", (it) => { + it.effect("binds a ticket to its destination and consumes it only once", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const store = yield* TcpForwardTicketStore.TcpForwardTicketStore; + const session = yield* sessions.issue({ + subject: "desktop", + scopes: ["terminal:operate"], + }); + const issued = yield* store.issue({ + sessionId: session.sessionId, + remoteHost: "127.0.0.1", + remotePort: 4321, + }); + + const consumed = yield* store.consume(issued.ticket); + expect(consumed.remoteHost).toBe("127.0.0.1"); + expect(consumed.remotePort).toBe(4321); + expect(consumed.session.sessionId).toBe(session.sessionId); + + const replay = yield* Effect.flip(store.consume(issued.ticket)); + expect(replay._tag).toBe("TcpForwardTicketInvalidError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects expired tickets", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const store = yield* TcpForwardTicketStore.TcpForwardTicketStore; + const session = yield* sessions.issue({ + subject: "desktop", + scopes: ["terminal:operate"], + }); + const issued = yield* store.issue({ + sessionId: session.sessionId, + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + yield* TestClock.adjust(Duration.seconds(31)); + + const expired = yield* Effect.flip(store.consume(issued.ticket)); + expect(expired._tag).toBe("TcpForwardTicketInvalidError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects sessions without terminal access", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const store = yield* TcpForwardTicketStore.TcpForwardTicketStore; + const session = yield* sessions.issue({ + subject: "read-only", + scopes: ["orchestration:read"], + }); + const issued = yield* store.issue({ + sessionId: AuthSessionId.make(session.sessionId), + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + + const rejected = yield* Effect.flip(store.consume(issued.ticket)); + expect(rejected._tag).toBe("TcpForwardTicketInvalidError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("rejects a ticket after its parent session is revoked", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const store = yield* TcpForwardTicketStore.TcpForwardTicketStore; + const session = yield* sessions.issue({ + subject: "desktop", + scopes: ["terminal:operate"], + }); + const issued = yield* store.issue({ + sessionId: session.sessionId, + remoteHost: "127.0.0.1", + remotePort: 3000, + }); + yield* sessions.revoke(session.sessionId); + + const revoked = yield* Effect.flip(store.consume(issued.ticket)); + expect(revoked._tag).toBe("TcpForwardTicketInvalidError"); + }).pipe(Effect.provide(testLayer)), + ); +}); diff --git a/apps/server/src/portForward/TcpForwardTicketStore.ts b/apps/server/src/portForward/TcpForwardTicketStore.ts new file mode 100644 index 000000000000..de6416322876 --- /dev/null +++ b/apps/server/src/portForward/TcpForwardTicketStore.ts @@ -0,0 +1,120 @@ +import { AuthTerminalOperateScope, type TcpPortForwardHost } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as SessionStore from "../auth/SessionStore.ts"; + +const TICKET_TTL = Duration.seconds(30); + +interface TicketRecord { + readonly webSocketToken: string; + readonly remoteHost: TcpPortForwardHost; + readonly remotePort: number; + readonly expiresAtEpochMs: number; +} + +export class TcpForwardTicketIssueError extends Schema.TaggedErrorClass()( + "TcpForwardTicketIssueError", + { cause: Schema.Defect() }, +) {} + +export class TcpForwardTicketInvalidError extends Schema.TaggedErrorClass()( + "TcpForwardTicketInvalidError", + {}, +) {} + +export class TcpForwardTicketStore extends Context.Service< + TcpForwardTicketStore, + { + readonly issue: (input: { + readonly sessionId: SessionStore.VerifiedSession["sessionId"]; + readonly remoteHost: TcpPortForwardHost; + readonly remotePort: number; + }) => Effect.Effect< + { readonly ticket: string; readonly expiresAt: DateTime.Utc }, + TcpForwardTicketIssueError + >; + readonly consume: (ticket: string) => Effect.Effect< + { + readonly session: SessionStore.VerifiedSession; + readonly remoteHost: TcpPortForwardHost; + readonly remotePort: number; + }, + TcpForwardTicketInvalidError + >; + } +>()("t3/portForward/TcpForwardTicketStore") {} + +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const sessions = yield* SessionStore.SessionStore; + const records = yield* Ref.make(new Map()); + + const issue: TcpForwardTicketStore["Service"]["issue"] = Effect.fn("TcpForwardTicketStore.issue")( + function* (input) { + const ticket = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new TcpForwardTicketIssueError({ cause })), + ); + const issued = yield* sessions + .issueWebSocketToken(input.sessionId, { ttl: TICKET_TTL }) + .pipe(Effect.mapError((cause) => new TcpForwardTicketIssueError({ cause }))); + yield* Ref.update(records, (current) => { + const next = new Map( + [...current].filter( + ([, record]) => + record.expiresAtEpochMs > + issued.expiresAt.epochMilliseconds - Duration.toMillis(TICKET_TTL), + ), + ); + next.set(ticket, { + webSocketToken: issued.token, + remoteHost: input.remoteHost, + remotePort: input.remotePort, + expiresAtEpochMs: issued.expiresAt.epochMilliseconds, + }); + return next; + }); + return { ticket, expiresAt: DateTime.toUtc(issued.expiresAt) }; + }, + ); + + const consume: TcpForwardTicketStore["Service"]["consume"] = Effect.fn( + "TcpForwardTicketStore.consume", + )(function* (ticket) { + const record = yield* Ref.modify(records, (current) => { + const next = new Map(current); + const found = next.get(ticket); + next.delete(ticket); + return [Option.fromUndefinedOr(found), next] as const; + }); + if (Option.isNone(record)) { + return yield* new TcpForwardTicketInvalidError({}); + } + const now = yield* DateTime.now; + if (record.value.expiresAtEpochMs <= now.epochMilliseconds) { + return yield* new TcpForwardTicketInvalidError({}); + } + const session = yield* sessions + .verifyWebSocketToken(record.value.webSocketToken) + .pipe(Effect.mapError(() => new TcpForwardTicketInvalidError({}))); + if (!session.scopes.includes(AuthTerminalOperateScope)) { + return yield* new TcpForwardTicketInvalidError({}); + } + return { + session, + remoteHost: record.value.remoteHost, + remotePort: record.value.remotePort, + }; + }); + + return TcpForwardTicketStore.of({ issue, consume }); +}); + +export const layer = Layer.effect(TcpForwardTicketStore, make); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..bd86ea94e404 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,8 @@ import { import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; +import { tcpPortForwardWebSocketRouteLayer } from "./portForward/TcpForwardBridge.ts"; +import * as TcpForwardTicketStore from "./portForward/TcpForwardTicketStore.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -458,12 +460,14 @@ export const makeRoutesLayer = Layer.mergeAll( assetRouteLayer, staticAndDevRouteLayer, websocketRpcRouteLayer, + tcpPortForwardWebSocketRouteLayer, ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), + Layer.provideMerge(TcpForwardTicketStore.layer), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index dbba327489ac..7f3af6c60419 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -45,6 +45,7 @@ import { WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; import { cn } from "~/lib/utils"; +import { DesktopPortForwardControl } from "../desktop/DesktopPortForwardControl"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -408,6 +409,10 @@ export const ChatHeader = memo(function ChatHeader({ {...(draftId ? { draftId } : {})} /> )} +
); diff --git a/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx b/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx new file mode 100644 index 000000000000..a2e0532b30b3 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx @@ -0,0 +1,42 @@ +import { resolveTcpPortForwardSocketUrl } from "@t3tools/client-runtime/authorization"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import * as Effect from "effect/Effect"; +import { useEffect } from "react"; + +import { runtime } from "../../lib/runtime"; +import { readPreparedConnection } from "../../state/session"; + +export function DesktopPortForwardAuthorizationBridge() { + useEffect(() => { + const bridge = window.desktopBridge?.portForward; + if (bridge === undefined) return; + + return bridge.onAuthorizationRequest((request) => { + const prepared = readPreparedConnection(request.environmentId); + if (prepared === null) { + void bridge.resolveAuthorization(request.requestId, null); + return; + } + + void runtime + .runPromise( + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + return yield* resolveTcpPortForwardSocketUrl({ + prepared, + signer, + remoteHost: request.remoteHost, + remotePort: request.remotePort, + }); + }), + ) + .then( + (socketUrl) => bridge.resolveAuthorization(request.requestId, socketUrl), + () => bridge.resolveAuthorization(request.requestId, null), + ) + .catch(() => undefined); + }); + }, []); + + return null; +} diff --git a/apps/web/src/components/desktop/DesktopPortForwardControl.test.ts b/apps/web/src/components/desktop/DesktopPortForwardControl.test.ts new file mode 100644 index 000000000000..fe31b35f69b6 --- /dev/null +++ b/apps/web/src/components/desktop/DesktopPortForwardControl.test.ts @@ -0,0 +1,48 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolvePortForwardEnvironmentId } from "./DesktopPortForwardControl"; + +const development = "development" as EnvironmentId; +const emaildev = "emaildev" as EnvironmentId; + +describe("resolvePortForwardEnvironmentId", () => { + it("follows the active thread when its environment changes", () => { + expect( + resolvePortForwardEnvironmentId({ + connectedEnvironmentIds: [development, emaildev], + preferredEnvironmentId: emaildev, + selection: { + contextEnvironmentId: development, + environmentId: development, + }, + }), + ).toBe(emaildev); + }); + + it("preserves an explicit selection within the current thread", () => { + expect( + resolvePortForwardEnvironmentId({ + connectedEnvironmentIds: [development, emaildev], + preferredEnvironmentId: emaildev, + selection: { + contextEnvironmentId: emaildev, + environmentId: development, + }, + }), + ).toBe(development); + }); + + it("falls back when the preferred environment is disconnected", () => { + expect( + resolvePortForwardEnvironmentId({ + connectedEnvironmentIds: [development], + preferredEnvironmentId: emaildev, + selection: { + contextEnvironmentId: emaildev, + environmentId: emaildev, + }, + }), + ).toBe(development); + }); +}); diff --git a/apps/web/src/components/desktop/DesktopPortForwardControl.tsx b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx new file mode 100644 index 000000000000..821c8084531c --- /dev/null +++ b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx @@ -0,0 +1,273 @@ +import type { + DesktopPortForwardId, + DesktopPortForwardSnapshot, + EnvironmentId, +} from "@t3tools/contracts"; +import { CableIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { useEnvironments } from "../../state/environments"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +function parsePort(value: string): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : null; +} + +interface EnvironmentSelection { + readonly contextEnvironmentId: EnvironmentId; + readonly environmentId: EnvironmentId; +} + +export function resolvePortForwardEnvironmentId(input: { + readonly connectedEnvironmentIds: ReadonlyArray; + readonly preferredEnvironmentId: EnvironmentId; + readonly selection: EnvironmentSelection; +}): EnvironmentId | null { + const { connectedEnvironmentIds, preferredEnvironmentId, selection } = input; + if ( + selection.contextEnvironmentId === preferredEnvironmentId && + connectedEnvironmentIds.includes(selection.environmentId) + ) { + return selection.environmentId; + } + if (connectedEnvironmentIds.includes(preferredEnvironmentId)) { + return preferredEnvironmentId; + } + return connectedEnvironmentIds[0] ?? null; +} + +export function DesktopPortForwardControl({ + preferredEnvironmentId, +}: { + preferredEnvironmentId: EnvironmentId; +}) { + const bridge = window.desktopBridge?.portForward; + const { environments } = useEnvironments(); + const connectedEnvironments = useMemo( + () => environments.filter((environment) => environment.connection.phase === "connected"), + [environments], + ); + const environmentLabels = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const [environmentSelection, setEnvironmentSelection] = useState(() => ({ + contextEnvironmentId: preferredEnvironmentId, + environmentId: preferredEnvironmentId, + })); + const [remotePort, setRemotePort] = useState("3000"); + const [localPort, setLocalPort] = useState(""); + const [forwards, setForwards] = useState>([]); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + const [stoppingId, setStoppingId] = useState(null); + + const selectedEnvironmentId = resolvePortForwardEnvironmentId({ + connectedEnvironmentIds: connectedEnvironments.map((environment) => environment.environmentId), + preferredEnvironmentId, + selection: environmentSelection, + }); + + useEffect(() => { + if (bridge === undefined) return; + let disposed = false; + void bridge.list().then( + (next) => { + if (!disposed) setForwards(next); + }, + () => undefined, + ); + const unsubscribe = bridge.onStateChange((next) => { + setForwards(next); + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, [bridge]); + + if (bridge === undefined) return null; + + const parsedRemotePort = parsePort(remotePort); + const parsedLocalPort = localPort.trim() === "" ? undefined : parsePort(localPort); + const canCreate = + selectedEnvironmentId !== null && + parsedRemotePort !== null && + parsedLocalPort !== null && + !creating; + + const create = async () => { + if (!canCreate || selectedEnvironmentId === null || parsedRemotePort === null) return; + setCreating(true); + setError(null); + try { + const created = await bridge.create({ + environmentId: selectedEnvironmentId, + remoteHost: "127.0.0.1", + remotePort: parsedRemotePort, + ...(parsedLocalPort === undefined ? {} : { localPort: parsedLocalPort }), + }); + setForwards((current) => + current.some((forward) => forward.id === created.id) ? current : [...current, created], + ); + setLocalPort(""); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setCreating(false); + } + }; + + const stop = async (id: DesktopPortForwardId) => { + setStoppingId(id); + setError(null); + try { + await bridge.stop(id); + setForwards((current) => current.filter((forward) => forward.id !== id)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setStoppingId(null); + } + }; + + return ( + + + + } + > + + {forwards.length > 0 ? ( + + {forwards.length} + + ) : null} + + } + /> + Port forwarding + + +
+

Port forwarding

+

+ Forward remote loopback ports to this computer. +

+
+ {connectedEnvironments.length === 0 ? ( +

+ Connect an environment before starting a port forward. +

+ ) : null} +
+ +
+ + +
+ +
+ {error === null ? null :

{error}

} + {forwards.length > 0 ? ( +
+ {forwards.map((forward) => ( +
+
+

127.0.0.1:{forward.localPort}

+

+ {environmentLabels.get(forward.environmentId) ?? "Unknown environment"} · → + 127.0.0.1:{forward.remotePort} + {forward.activeConnections > 0 ? ` · ${forward.activeConnections} active` : ""} +

+
+ +
+ ))} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c3bbd75cf116..6fd739ad68a8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -90,6 +90,7 @@ import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { AnimatedHeight } from "../AnimatedHeight"; import { Textarea } from "../ui/textarea"; +import { DesktopPortForwardsSettings } from "./DesktopPortForwardsSettings"; import { getPairingTokenFromUrl, setPairingTokenOnUrl } from "../../pairingUrl"; import { readHostedPairingRequest } from "../../hostedPairing"; import { @@ -3476,6 +3477,8 @@ export function ConnectionsSettings() { )} + + = 1 && parsed <= 65_535 ? parsed : null; +} + +export function DesktopPortForwardsSettings() { + const bridge = window.desktopBridge?.portForward; + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const [environmentId, setEnvironmentId] = useState(null); + const [remotePort, setRemotePort] = useState("3000"); + const [localPort, setLocalPort] = useState(""); + const [forwards, setForwards] = useState>([]); + const [error, setError] = useState(null); + const [creating, setCreating] = useState(false); + const [stoppingId, setStoppingId] = useState(null); + const connectedEnvironments = useMemo( + () => environments.filter((environment) => environment.connection.phase === "connected"), + [environments], + ); + + useEffect(() => { + if ( + environmentId !== null && + connectedEnvironments.some((environment) => environment.environmentId === environmentId) + ) { + return; + } + const primaryConnected = connectedEnvironments.some( + (environment) => environment.environmentId === primaryEnvironment?.environmentId, + ); + const fallback = primaryConnected + ? (primaryEnvironment?.environmentId ?? null) + : (connectedEnvironments[0]?.environmentId ?? null); + setEnvironmentId(fallback); + }, [connectedEnvironments, environmentId, primaryEnvironment?.environmentId]); + + useEffect(() => { + if (bridge === undefined) return; + let disposed = false; + void bridge.list().then( + (next) => { + if (!disposed) setForwards(next); + }, + (cause) => { + if (!disposed) setError(cause instanceof Error ? cause.message : String(cause)); + }, + ); + const unsubscribe = bridge.onStateChange((next) => setForwards(next)); + return () => { + disposed = true; + unsubscribe(); + }; + }, [bridge]); + + const labels = useMemo( + () => + new Map(environments.map((environment) => [environment.environmentId, environment.label])), + [environments], + ); + if (bridge === undefined) return null; + + const parsedRemotePort = parsePort(remotePort); + const parsedLocalPort = localPort.trim() === "" ? undefined : parsePort(localPort); + const canCreate = + environmentId !== null && parsedRemotePort !== null && parsedLocalPort !== null && !creating; + + const create = async () => { + if (!canCreate || environmentId === null || parsedRemotePort === null) return; + setCreating(true); + setError(null); + try { + const created = await bridge.create({ + environmentId, + remoteHost: "127.0.0.1", + remotePort: parsedRemotePort, + ...(parsedLocalPort === undefined ? {} : { localPort: parsedLocalPort }), + }); + setForwards((current) => + current.some((forward) => forward.id === created.id) ? current : [...current, created], + ); + setLocalPort(""); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setCreating(false); + } + }; + + const stop = async (id: DesktopPortForwardId) => { + setStoppingId(id); + setError(null); + try { + await bridge.stop(id); + setForwards((current) => current.filter((forward) => forward.id !== id)); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setStoppingId(null); + } + }; + + return ( + + {error}} + > +
+ + + + +
+
+ {forwards.map((forward) => ( + {forward.lastError} + ) + } + control={ + + } + /> + ))} +
+ ); +} diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 46b2c8eb1ba0..50dfcd984955 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -108,6 +108,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { function SelectPopup({ className, popupClassName, + positionerClassName, children, side = "bottom", sideOffset = 4, @@ -119,6 +120,7 @@ function SelectPopup({ ...props }: SelectPrimitive.Popup.Props & { popupClassName?: string; + positionerClassName?: string; side?: SelectPrimitive.Positioner.Props["side"]; sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]; align?: SelectPrimitive.Positioner.Props["align"]; @@ -134,7 +136,7 @@ function SelectPopup({ alignItemWithTrigger={alignItemWithTrigger} alignOffset={alignOffset} anchor={anchor} - className="z-[130] select-none" + className={cn("z-[130] select-none", positionerClassName)} data-slot="select-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index cb82d300835f..ef9592a2b38f 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -597,8 +597,14 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ clear: (environmentId) => - Effect.sync(() => { + Effect.gen(function* () { clearComposerDraftsEnvironment(environmentId); + const portForward = window.desktopBridge?.portForward; + if (portForward !== undefined) { + yield* Effect.promise(() => portForward.stopEnvironment(environmentId)).pipe( + Effect.catchCause(() => Effect.void), + ); + } }), }), ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7c715dff9e95..eadd1e07730c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -18,6 +18,7 @@ import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { DesktopPortForwardAuthorizationBridge } from "../components/desktop/DesktopPortForwardAuthorizationBridge"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; @@ -139,6 +140,7 @@ function RootRouteView() { + diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index ce43faacc40f..8cd80de063f9 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -101,6 +101,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ) .handle("token", () => unexpectedEndpoint("auth.token")) .handle("webSocketTicket", () => unexpectedEndpoint("auth.webSocketTicket")) + .handle("tcpPortForwardTicket", () => unexpectedEndpoint("auth.tcpPortForwardTicket")) .handle( "pairingCredential", Effect.fn("test.environment.auth.pairingCredential")(function* ({ payload }) { diff --git a/docs/internals/desktop-port-forwarding.md b/docs/internals/desktop-port-forwarding.md new file mode 100644 index 000000000000..af532de5b7bb --- /dev/null +++ b/docs/internals/desktop-port-forwarding.md @@ -0,0 +1,168 @@ +# Desktop Port Forwarding + +> The bridge-first manual forwarding slice is implemented. Persistence, +> preview discovery actions, and a native SSH driver remain follow-up work. + +## Decision + +T3 Code provides environment-scoped local TCP forwarding in the desktop app. + +The initial feature maps a loopback listener on the desktop to a loopback TCP +service on one selected remote environment: + +```text +development 127.0.0.1:3000 -> desktop 127.0.0.1:43001 +primary 127.0.0.1:3000 -> desktop 127.0.0.1:43002 +development 127.0.0.1:5432 -> desktop 127.0.0.1:45432 +``` + +It is a private client-side tunnel, not a mechanism for publishing remote +services on the Internet. + +## Transport + +Do not allocate an additional FRP proxy for each forwarded port. The sovereign +FRP authorization boundary intentionally permits one canonical HTTP proxy per +environment. Expanding it would require public endpoint allocation, lifecycle +reconciliation, and materially broader Internet-facing authorization. + +Instead, carry forwarding traffic through the environment's existing +authenticated HTTPS/WSS endpoint: + +```text +desktop loopback listener + -> Electron PortForwardManager + -> dedicated authenticated binary WebSocket + -> existing T3 environment endpoint and FRP tunnel + -> remote T3 TCP bridge + -> remote loopback service +``` + +Use one dedicated WebSocket per accepted TCP connection for the first version. +This keeps framing, half-close behavior, cancellation, and backpressure simpler +than a new multiplexing protocol and avoids head-of-line blocking the ordinary +T3 control connection. Multiplex only if measured connection counts justify it. + +The server must advertise an additive `tcpPortForwarding` environment +capability. The implementation should remain generic T3 behavior rather than +sovereign-only code: + +- T3 Connect environments use the authenticated WebSocket bridge. +- SSH environments may use native `ssh -L` behind the same desktop UI. +- Local environments do not need a tunnel. + +## Multi-environment ownership + +Electron main owns one process-wide `PortForwardManager`. A forward has a +stable `forwardId` and is scoped internally by `environmentId`, remote host, +and remote port. Display labels are never identity: duplicate environment +labels must remain harmless. + +The manager currently owns: + +- loopback listeners and atomic port allocation; +- active socket/WebSocket pairs; +- runtime-only definitions; +- account-switch and remote-revocation teardown; +- per-forward and global connection limits. + +Persistence, auto-start, waiting/reconnecting states, and creation +deduplication remain part of the next lifecycle-focused slice. + +A persisted definition should contain at least: + +```ts +interface SavedPortForward { + readonly forwardId: string; + readonly environmentId: string; + readonly remoteHost: "127.0.0.1" | "::1" | "localhost"; + readonly remotePort: number; + readonly preferredLocalPort: number | null; + readonly autoStart: boolean; + readonly label: string | null; +} +``` + +The assigned local port is device-local runtime state. It should not be synced +through the relay. Bind the real listener atomically; never probe a free port, +release it, and bind later. If a requested port is occupied, report a conflict +or visibly allocate another port—never silently replace an existing mapping. + +When an environment is unavailable, retain its saved definition in a waiting +state. Reconcile it when that exact environment ID returns. Removing or +revoking an environment and switching accounts must immediately close its +listeners and all active connections. + +## User experience + +The desktop Connections settings include a Port forwarding panel with one row +per running definition. The current panel shows: + +- environment label plus a short environment-ID fingerprint; +- remote destination and assigned desktop address; +- running/error status; +- active connection count; +- manual creation and stop actions. + +Copy address, open HTTP service, edit, persistence, and richer lifecycle states +remain follow-ups. + +The remote server already discovers common loopback development ports. Add a +one-click **Forward to this Mac** action to those results while retaining a +manual TCP-port form. Prefer the same local and remote port when available; +allocate a clear alternate when several environments expose the same port. + +HTTP-aware host-header rewriting, friendly `*.localhost` names, and reverse +forwarding are separate follow-ups. The initial primitive remains transparent +TCP, so applications that require a specific HTTP Host or TLS hostname may +need an explicit later HTTP mode. + +## Security boundary + +The first release must be deliberately narrow: + +- TCP only; no UDP. +- Desktop only; web and mobile cannot create operating-system listeners. +- Bind only desktop loopback, never `0.0.0.0` or a LAN address. +- Dial only remote loopback, never arbitrary LAN, container-network, or public + destinations. +- Authorize each connection with a short-lived, single-use, + environment-scoped ticket bound to destination and expiration. +- Enforce port validation, bounded buffers, flow control, connection limits, + idle timeouts, and deterministic half-close/cancellation behavior. +- Log metadata and lifecycle only; never log forwarded bytes or credentials. +- Terminate immediately on sign-out, account switch, environment removal, + remote revocation, or authorization expiry. + +Arbitrary remote destinations would turn an environment into a network pivot. +LAN-visible desktop listeners would expose remote services to the desktop's +network. Both require separate explicit designs and must not arrive as hidden +options in the initial dialog. + +## Existing foundations + +- `packages/ssh/src/tunnel.ts` already demonstrates loopback allocation, + tunnel supervision, pending-entry deduplication, readiness, and cleanup. +- The server preview port scanner already finds remote development listeners. +- Contracts and RPC already support streaming operations and byte arrays. +- Environment IDs provide stable multi-remote scoping. +- T3 Connect supplies the authenticated TLS/WSS path. +- Electron already owns IPC validation and local saved-environment state. + +The implemented slice includes the authenticated TCP bridge, binary connection +protocol, Electron manager, manual UI, bounded flow control, environment-removal +cleanup, idle timeouts, and connection limits. Persistence and deeper lifecycle +reconciliation remain. + +## Delivery order and estimate + +1. **Complete:** Contracts, capability, ticket scope, and remote loopback TCP bridge. +2. **Complete:** Dedicated binary WebSocket with bounded flow control and close semantics. +3. **Partial:** Electron manager with atomic listeners and environment-removal cleanup. +4. **Partial:** Runtime-only definitions and manual desktop UI; persistence remains. +5. SSH driver integration behind the same model. +6. Failure-recovery, account-switch, revocation, load, and adversarial tests. + +A proof of concept is several focused days. A production-quality TCP feature +with persistence, reconnect behavior, conflict handling, limits, UI, and +security verification is approximately two to four focused engineering weeks. diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 674c466ebf11..9b91e033ee1d 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -187,6 +187,29 @@ generated runner script, clears stale launcher state, and starts a fresh server discover or reuse a live one. You should not normally need to delete `~/.t3/ssh-launch` or kill `t3` processes manually. +### Forward a Remote Port to the Desktop + +The desktop app can expose a service listening on an environment's loopback +interface as a loopback port on your computer. This works with T3 Connect, +direct HTTPS or Tailscale connections, and desktop-managed SSH environments. + +1. Open **Settings** → **Connections** in the desktop app. +2. Under **Port forwarding**, select an environment. +3. Enter the remote port. Leave the local port blank to choose an available + port automatically. T3 Code first tries the same port, then nearby ports, + and finally an operating-system-assigned free port. Enter a local port when + you need an exact mapping; an exact-port conflict is reported instead of + silently changing it. +4. Choose **Start** and connect to the displayed `127.0.0.1` address. + +For the active environment, the cable icon in the conversation header opens a +compact version of the same controls and shows how many forwards are running. + +The forward is TCP-only and can reach only `127.0.0.1` on the remote +environment. It is not exposed to your LAN or the public Internet. Manual +forwards are currently runtime-only: they stop when you quit the desktop app, +remove the environment, or choose **Stop**. + ## Updating a Remote Server When the T3 Code web or desktop app and a remote server use different versions, a warning appears in diff --git a/packages/client-runtime/src/authorization/index.ts b/packages/client-runtime/src/authorization/index.ts index 6236b5922d82..7c8703c04145 100644 --- a/packages/client-runtime/src/authorization/index.ts +++ b/packages/client-runtime/src/authorization/index.ts @@ -1,4 +1,5 @@ export * from "./remote.ts"; +export * from "./portForward.ts"; export { type AuthorizedRemoteEnvironment, type RelayEnvironmentAuthorization, diff --git a/packages/client-runtime/src/authorization/portForward.test.ts b/packages/client-runtime/src/authorization/portForward.test.ts new file mode 100644 index 000000000000..03b7e4f74bf0 --- /dev/null +++ b/packages/client-runtime/src/authorization/portForward.test.ts @@ -0,0 +1,57 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { BearerConnectionTarget } from "../connection/model.ts"; +import { remoteHttpClientLayer } from "../rpc/http.ts"; +import { resolveTcpPortForwardSocketUrl } from "./portForward.ts"; + +describe("TCP port forward authorization", () => { + it.effect("issues a destination-bound bearer ticket and returns the bridge URL", () => + Effect.gen(function* () { + const calls: Array = []; + const fetchFn = ((input, init) => { + calls.push([input, init ?? {}]); + return Promise.resolve( + Response.json({ + ticket: "single-use-ticket", + expiresAt: "2026-08-12T12:00:30.000Z", + }), + ); + }) satisfies typeof fetch; + const environmentId = EnvironmentId.make("environment-a"); + + const socketUrl = yield* resolveTcpPortForwardSocketUrl({ + prepared: { + environmentId, + label: "Remote", + httpBaseUrl: "https://remote.example.com/base", + socketUrl: "wss://remote.example.com/ws?wsTicket=control-ticket", + httpAuthorization: { _tag: "Bearer", token: "access-token" }, + target: new BearerConnectionTarget({ + environmentId, + label: "Remote", + connectionId: "remote-a", + }), + }, + signer: Option.none(), + remoteHost: "127.0.0.1", + remotePort: 4321, + }).pipe(Effect.provide(remoteHttpClientLayer(fetchFn))); + + expect(socketUrl).toBe("wss://remote.example.com/ws/tcp-forward?ticket=single-use-ticket"); + expect(String(calls[0]?.[0])).toBe("https://remote.example.com/api/auth/tcp-forward-ticket"); + expect(calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ authorization: "Bearer access-token" }), + }), + ); + const body = calls[0]?.[1].body; + expect(typeof body === "string" ? body : new TextDecoder().decode(body as Uint8Array)).toBe( + '{"remoteHost":"127.0.0.1","remotePort":4321}', + ); + }), + ); +}); diff --git a/packages/client-runtime/src/authorization/portForward.ts b/packages/client-runtime/src/authorization/portForward.ts new file mode 100644 index 000000000000..efc31b11a200 --- /dev/null +++ b/packages/client-runtime/src/authorization/portForward.ts @@ -0,0 +1,55 @@ +import type { TcpPortForwardHost } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { + buildEnvironmentAuthHeaders, + withEnvironmentCredentials, +} from "../state/environmentHttpAuth.ts"; + +const DEFAULT_TCP_FORWARD_TICKET_TIMEOUT_MS = 10_000; + +export const resolveTcpPortForwardSocketUrl = Effect.fn( + "clientRuntime.authorization.resolveTcpPortForwardSocketUrl", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly signer: Option.Option; + readonly remoteHost: TcpPortForwardHost; + readonly remotePort: number; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl( + input.prepared.httpBaseUrl, + "/api/auth/tcp-forward-ticket", + ); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "POST", + requestUrl, + input.signer, + ); + const issued = yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_TCP_FORWARD_TICKET_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.auth.tcpPortForwardTicket({ + headers, + payload: { remoteHost: input.remoteHost, remotePort: input.remotePort }, + }), + ), + ); + + const socketUrl = new URL(input.prepared.httpBaseUrl); + socketUrl.protocol = socketUrl.protocol === "https:" ? "wss:" : "ws:"; + socketUrl.pathname = "/ws/tcp-forward"; + socketUrl.search = ""; + socketUrl.hash = ""; + socketUrl.searchParams.set("ticket", issued.ticket); + return socketUrl.toString(); +}); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..1c72159881ca 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -80,6 +80,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server accepts destination-bound, single-use tickets for TCP forwarding + to its own loopback interface. */ + tcpPortForwarding: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index e7494862251e..b5d3a6d8abe9 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -26,6 +26,7 @@ import { } from "./auth.ts"; import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { TcpPortForwardTicketRequest, TcpPortForwardTicketResult } from "./portForward.ts"; import { ClientOrchestrationCommand, DispatchResult, @@ -82,6 +83,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([ "browser_session_cookie_failed", "access_token_issuance_failed", "websocket_ticket_issuance_failed", + "tcp_forward_ticket_issuance_failed", "pairing_credential_issuance_failed", "pairing_links_load_failed", "pairing_link_revoke_failed", @@ -437,6 +439,14 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") error: [EnvironmentInternalError], }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.post("tcpPortForwardTicket", "/api/auth/tcp-forward-ticket", { + headers: OptionalBearerHeaders, + payload: TcpPortForwardTicketRequest, + success: TcpPortForwardTicketResult, + error: [EnvironmentScopeRequiredError, EnvironmentInternalError], + }).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("pairingCredential", "/api/auth/pairing-token", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..98865a28eecb 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6,6 +6,7 @@ export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; +export * from "./portForward.ts"; export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e753596f3d33..9091a99b8ef2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -91,6 +91,7 @@ import { EnvironmentId } from "./baseSchemas.ts"; import { AuthAccessTokenResult, AuthSessionState, AuthWebSocketTicketResult } from "./auth.ts"; import { AdvertisedEndpoint } from "./remoteAccess.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import type { DesktopPortForwardBridge } from "./portForward.ts"; import type { ClientSettings } from "./settings.ts"; import type { EditorId } from "./editor.ts"; import type { @@ -1145,6 +1146,8 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Desktop-owned loopback listeners. Optional for desktop/web version skew. */ + portForward?: DesktopPortForwardBridge; /** * Desktop-only preview surface. Present iff the renderer is hosted by the * Electron desktop build; web builds have `preview === undefined`. diff --git a/packages/contracts/src/portForward.ts b/packages/contracts/src/portForward.ts new file mode 100644 index 000000000000..041d80c3fcaa --- /dev/null +++ b/packages/contracts/src/portForward.ts @@ -0,0 +1,97 @@ +import * as Schema from "effect/Schema"; + +import { EnvironmentId, NonNegativeInt, PortSchema, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const TcpPortForwardHost = Schema.Literal("127.0.0.1"); +export type TcpPortForwardHost = typeof TcpPortForwardHost.Type; + +export const TcpPort = PortSchema; +export type TcpPort = typeof TcpPort.Type; + +export const TcpPortForwardTicketRequest = Schema.Struct({ + remoteHost: TcpPortForwardHost, + remotePort: TcpPort, +}); +export type TcpPortForwardTicketRequest = typeof TcpPortForwardTicketRequest.Type; + +export const TcpPortForwardTicketResult = Schema.Struct({ + ticket: TrimmedNonEmptyString, + expiresAt: Schema.DateTimeUtc, +}); +export type TcpPortForwardTicketResult = typeof TcpPortForwardTicketResult.Type; + +export const DesktopPortForwardId = Schema.String.pipe(Schema.brand("DesktopPortForwardId")); +export type DesktopPortForwardId = typeof DesktopPortForwardId.Type; + +export const DesktopPortForwardStatus = Schema.Literals(["running", "failed"]); +export type DesktopPortForwardStatus = typeof DesktopPortForwardStatus.Type; + +export const DesktopPortForwardCreateInput = Schema.Struct({ + environmentId: EnvironmentId, + remoteHost: TcpPortForwardHost, + remotePort: TcpPort, + localPort: Schema.optionalKey(TcpPort), +}); +export type DesktopPortForwardCreateInput = typeof DesktopPortForwardCreateInput.Type; + +export const DesktopPortForwardSnapshot = Schema.Struct({ + id: DesktopPortForwardId, + environmentId: EnvironmentId, + localHost: TcpPortForwardHost, + localPort: TcpPort, + remoteHost: TcpPortForwardHost, + remotePort: TcpPort, + status: DesktopPortForwardStatus, + activeConnections: NonNegativeInt, + lastError: Schema.NullOr(Schema.String), +}); +export type DesktopPortForwardSnapshot = typeof DesktopPortForwardSnapshot.Type; + +export const DesktopPortForwardStopInput = Schema.Struct({ id: DesktopPortForwardId }); +export type DesktopPortForwardStopInput = typeof DesktopPortForwardStopInput.Type; + +export const DesktopPortForwardStopEnvironmentInput = Schema.Struct({ + environmentId: EnvironmentId, +}); +export type DesktopPortForwardStopEnvironmentInput = + typeof DesktopPortForwardStopEnvironmentInput.Type; + +export const DesktopPortForwardAuthorizationRequest = Schema.Struct({ + requestId: TrimmedNonEmptyString, + forwardId: DesktopPortForwardId, + environmentId: EnvironmentId, + remoteHost: TcpPortForwardHost, + remotePort: TcpPort, +}); +export type DesktopPortForwardAuthorizationRequest = + typeof DesktopPortForwardAuthorizationRequest.Type; + +export const DesktopPortForwardAuthorizationResolution = Schema.Struct({ + requestId: TrimmedNonEmptyString, + socketUrl: Schema.NullOr(Schema.String), +}); +export type DesktopPortForwardAuthorizationResolution = + typeof DesktopPortForwardAuthorizationResolution.Type; + +/** Binary framing shared by the desktop listener and environment bridge. */ +export const TCP_PORT_FORWARD_FRAME_DATA = 0; +export const TCP_PORT_FORWARD_FRAME_WRITE_END = 1; +export const TCP_PORT_FORWARD_FRAME_ACK = 2; +export const TCP_PORT_FORWARD_FRAME_ERROR = 3; +export const TCP_PORT_FORWARD_FRAME_CLOSE = 4; +export const TCP_PORT_FORWARD_INITIAL_CREDIT = 256 * 1024; +export const TCP_PORT_FORWARD_MAX_DATA_SIZE = 64 * 1024; + +export interface DesktopPortForwardBridge { + create: (input: DesktopPortForwardCreateInput) => Promise; + list: () => Promise>; + stop: (id: DesktopPortForwardId) => Promise; + stopEnvironment: (environmentId: EnvironmentId) => Promise; + resolveAuthorization: (requestId: string, socketUrl: string | null) => Promise; + onStateChange: ( + listener: (forwards: ReadonlyArray) => void, + ) => () => void; + onAuthorizationRequest: ( + listener: (request: DesktopPortForwardAuthorizationRequest) => void, + ) => () => void; +} From 3a14550833695bf97d07c509c3cd8e3ef9ceb47e Mon Sep 17 00:00:00 2001 From: will Date: Thu, 13 Aug 2026 19:47:36 -0400 Subject: [PATCH 11/26] fix(desktop): make port forwards reliable --- apps/desktop/src/ipc/methods/portForward.ts | 3 +- .../DesktopPortForwardManager.test.ts | 12 ++++ .../portForward/DesktopPortForwardManager.ts | 10 ++- apps/desktop/src/preload.ts | 3 +- .../DesktopPortForwardAuthorizationBridge.tsx | 58 +++++++++------- .../desktop/DesktopPortForwardControl.tsx | 24 ++++++- .../settings/DesktopPortForwardsSettings.tsx | 25 +++++-- apps/web/src/state/session.ts | 20 +++++- .../client-runtime/src/state/session.test.ts | 68 ++++++++++++++++++- packages/client-runtime/src/state/session.ts | 17 +++++ packages/contracts/src/portForward.ts | 7 +- 11 files changed, 208 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/ipc/methods/portForward.ts b/apps/desktop/src/ipc/methods/portForward.ts index 853660879db7..120e4d6f663e 100644 --- a/apps/desktop/src/ipc/methods/portForward.ts +++ b/apps/desktop/src/ipc/methods/portForward.ts @@ -73,8 +73,9 @@ export const resolvePortForwardAuthorization = DesktopIpc.makeIpcMethod({ handler: Effect.fn("desktop.ipc.portForward.resolveAuthorization")(function* ({ requestId, socketUrl, + error, }) { const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; - yield* manager.resolveAuthorization(requestId, socketUrl); + yield* manager.resolveAuthorization(requestId, socketUrl, error); }), }); diff --git a/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts b/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts index c3df1c59d19f..d38785f97ffa 100644 --- a/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts +++ b/apps/desktop/src/portForward/DesktopPortForwardManager.test.ts @@ -6,6 +6,18 @@ import * as Effect from "effect/Effect"; import * as DesktopPortForwardManager from "./DesktopPortForwardManager.ts"; it.layer(NodeServices.layer)("DesktopPortForwardManager", (it) => { + it("preserves renderer authorization failures for the forward status", () => { + const error = new DesktopPortForwardManager.DesktopPortForwardError({ + operation: "authorize", + cause: "Remote environment returned 404.", + detail: "Remote environment returned 404", + }); + + expect(error.message).toBe( + "Desktop port forward authorize failed: Remote environment returned 404.", + ); + }); + it.effect("atomically allocates and stops a desktop loopback listener", () => Effect.gen(function* () { const manager = yield* DesktopPortForwardManager.DesktopPortForwardManager; diff --git a/apps/desktop/src/portForward/DesktopPortForwardManager.ts b/apps/desktop/src/portForward/DesktopPortForwardManager.ts index 4625e838d689..fadae7dc2468 100644 --- a/apps/desktop/src/portForward/DesktopPortForwardManager.ts +++ b/apps/desktop/src/portForward/DesktopPortForwardManager.ts @@ -44,10 +44,11 @@ interface ManagedForward { export class DesktopPortForwardError extends Schema.TaggedErrorClass()( "DesktopPortForwardError", - { operation: Schema.String, cause: Schema.Defect() }, + { operation: Schema.String, cause: Schema.Defect(), detail: Schema.optionalKey(Schema.String) }, ) { override get message(): string { - return `Desktop port forward ${this.operation} failed.`; + const suffix = this.detail === undefined ? "" : `: ${this.detail}`; + return `Desktop port forward ${this.operation} failed${suffix}.`; } } @@ -341,6 +342,7 @@ export class DesktopPortForwardManager extends Context.Service< readonly resolveAuthorization: ( requestId: string, socketUrl: string | null, + error?: string, ) => Effect.Effect; readonly subscribeStateChanges: ( listener: StateListener, @@ -549,6 +551,7 @@ export const make = Effect.gen(function* () { const resolveAuthorization: DesktopPortForwardManager["Service"]["resolveAuthorization"] = ( requestId, socketUrl, + error, ) => Effect.gen(function* () { const current = yield* Ref.get(pendingAuthorizations); @@ -559,7 +562,8 @@ export const make = Effect.gen(function* () { deferred, new DesktopPortForwardError({ operation: "authorize", - cause: "The environment could not authorize this connection.", + cause: error ?? "The environment could not authorize this connection.", + ...(error === undefined ? {} : { detail: error }), }), ); } else { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0c6a2b54daef..257f9404650d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -118,10 +118,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { stop: (id) => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_STOP_CHANNEL, { id }), stopEnvironment: (environmentId) => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_STOP_ENVIRONMENT_CHANNEL, { environmentId }), - resolveAuthorization: (requestId, socketUrl) => + resolveAuthorization: (requestId, socketUrl, error) => ipcRenderer.invoke(IpcChannels.PORT_FORWARD_RESOLVE_AUTHORIZATION_CHANNEL, { requestId, socketUrl, + ...(error === undefined ? {} : { error }), }), onStateChange: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, snapshots: unknown) => { diff --git a/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx b/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx index a2e0532b30b3..b85404fc9d09 100644 --- a/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx +++ b/apps/web/src/components/desktop/DesktopPortForwardAuthorizationBridge.tsx @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import { useEffect } from "react"; import { runtime } from "../../lib/runtime"; -import { readPreparedConnection } from "../../state/session"; +import { readCurrentPreparedConnection } from "../../state/session"; export function DesktopPortForwardAuthorizationBridge() { useEffect(() => { @@ -12,29 +12,39 @@ export function DesktopPortForwardAuthorizationBridge() { if (bridge === undefined) return; return bridge.onAuthorizationRequest((request) => { - const prepared = readPreparedConnection(request.environmentId); - if (prepared === null) { - void bridge.resolveAuthorization(request.requestId, null); - return; - } - - void runtime - .runPromise( - Effect.gen(function* () { - const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); - return yield* resolveTcpPortForwardSocketUrl({ - prepared, - signer, - remoteHost: request.remoteHost, - remotePort: request.remotePort, - }); - }), - ) - .then( - (socketUrl) => bridge.resolveAuthorization(request.requestId, socketUrl), - () => bridge.resolveAuthorization(request.requestId, null), - ) - .catch(() => undefined); + void readCurrentPreparedConnection(request.environmentId) + .then((prepared) => { + if (prepared === null) { + // Authorization requests are broadcast to every desktop window. + // Only the renderer that owns a live connection should answer. + return; + } + return runtime.runPromise( + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + return yield* resolveTcpPortForwardSocketUrl({ + prepared, + signer, + remoteHost: request.remoteHost, + remotePort: request.remotePort, + }); + }), + ); + }) + .then((socketUrl) => { + if (socketUrl !== undefined) { + return bridge.resolveAuthorization(request.requestId, socketUrl); + } + }) + .catch((cause) => + bridge + .resolveAuthorization( + request.requestId, + null, + cause instanceof Error ? cause.message : String(cause), + ) + .catch(() => undefined), + ); }); }, []); diff --git a/apps/web/src/components/desktop/DesktopPortForwardControl.tsx b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx index 821c8084531c..1d2259fcfe32 100644 --- a/apps/web/src/components/desktop/DesktopPortForwardControl.tsx +++ b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx @@ -7,6 +7,7 @@ import { CableIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useEnvironments } from "../../state/environments"; +import { useServerConfigs } from "../../state/entities"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -48,10 +49,20 @@ export function DesktopPortForwardControl({ }) { const bridge = window.desktopBridge?.portForward; const { environments } = useEnvironments(); + const serverConfigs = useServerConfigs(); const connectedEnvironments = useMemo( () => environments.filter((environment) => environment.connection.phase === "connected"), [environments], ); + const forwardableEnvironments = useMemo( + () => + connectedEnvironments.filter( + (environment) => + serverConfigs.get(environment.environmentId)?.environment.capabilities + .tcpPortForwarding === true, + ), + [connectedEnvironments, serverConfigs], + ); const environmentLabels = useMemo( () => new Map( @@ -71,7 +82,9 @@ export function DesktopPortForwardControl({ const [stoppingId, setStoppingId] = useState(null); const selectedEnvironmentId = resolvePortForwardEnvironmentId({ - connectedEnvironmentIds: connectedEnvironments.map((environment) => environment.environmentId), + connectedEnvironmentIds: forwardableEnvironments.map( + (environment) => environment.environmentId, + ), preferredEnvironmentId, selection: environmentSelection, }); @@ -179,6 +192,10 @@ export function DesktopPortForwardControl({

Connect an environment before starting a port forward.

+ ) : forwardableEnvironments.length === 0 ? ( +

+ Update the connected T3 server before starting a port forward. +

) : null}
+ + {connectedEnvironments.length === 0 ? ( +

+ Connect an environment to start forwarding ports. +

+ ) : forwardableEnvironments.length === 0 ? ( +

- - - {environmentId === null - ? "Select" - : (environmentLabels.get(environmentId) ?? environmentId)} - - - - {forwardableEnvironments.map((environment) => ( - - {environment.label} - - ))} - - - {connectedEnvironments.length === 0 ? ( - - Connect an environment to start forwarding ports. - - ) : forwardableEnvironments.length === 0 ? ( - - Update the connected T3 server to enable port forwarding. - - ) : null} - - - - + Update the connected T3 server to enable port forwarding. +

+ ) : null} + {parsedRemotePort === null ? ( +

+ Remote port: enter a value from 1 to 65535. +

+ ) : null} + {localPort.trim() !== "" && parsedLocalPort === null ? ( +

+ Local port: enter a value from 1 to 65535. +

+ ) : null} {forwards.map((forward) => ( From 255a9cf447cbf4083a2431865f7b46b94b99c2de Mon Sep 17 00:00:00 2001 From: will Date: Sat, 22 Aug 2026 16:19:54 -0400 Subject: [PATCH 25/26] fix(web): align port forwarding field semantics --- .../desktop/DesktopPortForwardControl.tsx | 32 ++++++++++++++----- .../settings/DesktopPortForwardsSettings.tsx | 12 +++---- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/desktop/DesktopPortForwardControl.tsx b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx index c4cccae15fdd..d481cbce2746 100644 --- a/apps/web/src/components/desktop/DesktopPortForwardControl.tsx +++ b/apps/web/src/components/desktop/DesktopPortForwardControl.tsx @@ -140,8 +140,8 @@ export function DesktopPortForwardControl({

) : null}
-