From 65600ed324c4d1f5f5c0863686d6007c1c88e35e Mon Sep 17 00:00:00 2001 From: will Date: Sat, 15 Aug 2026 01:12:54 -0400 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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}