From f5b5f9dd7be29c4f55f4687dda15e4105cf966fc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 14 Oct 2026 04:08:07 -0700 Subject: [PATCH] fix(server): a failed T3 Connect relink no longer leaves a stale managed tunnel (#19383) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/CloudLink.lifecycle.test.ts | 56 +++++++++++++++++++ apps/server/src/cloud/CloudLink.ts | 14 +++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index dd211569277861b6452a819b71077c258610dd35..8ff5a063e6bd9aa46dfc10dcd2e34789ef4dede5 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -38,6 +38,7 @@ import { decodeConfirmedOrigin, decodeRuntimeConfig, RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_ISSUER_SECRET, RELAY_URL_SECRET, } from "./config.ts"; import * as CloudLink from "./CloudLink.ts"; @@ -1158,4 +1159,59 @@ describe("releaseManagedTunnelOnShutdown", () => { }), ); }); + + // The relay commits the link before the server stores it (#11898). A write + // that fails part-way must not leave the stale tunnel config that reports + // the link as managed, or Settings never relinks. + it.effect("does not report a managed tunnel after a relay config write fails part-way", () => { + const { store, values } = makeMemorySecretStore([ + [CLOUD_ENDPOINT_RUNTIME_CONFIG, "managed-config"], + [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"], + [RELAY_URL_SECRET, "https://relay.example.test"], + [CLOUD_LINKED_USER_ID, "user-123"], + [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"], + ]); + const failingStore: ServerSecretStore.ServerSecretStore["Service"] = { + ...store, + set: (name, value) => + name === RELAY_ISSUER_SECRET + ? Effect.fail( + new ServerSecretStore.SecretStorePersistError({ + resource: name, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + pathOrDescriptor: `${name}.bin`, + }), + }), + ) + : store.set(name, value), + }; + const applyConfigCalls: Array = []; + const requests: Array = []; + const cloudMintPublicKey = + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAMY09Vo5CzEA6aEkhB/0h27t4Qs71SHS1fTSI4K0KvZQ=\n-----END PUBLIC KEY-----\n"; + + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + const error = yield* Effect.flip( + link.applyRelayConfig({ + relayUrl: "https://relay.example.test", + cloudUserId: "user-123", + environmentCredential: "new-credential", + cloudMintPublicKey, + endpointRuntime: null, + }), + ); + + expect(error._tag).toBe("CloudLinkInternalError"); + expect(yield* link.linkState()).toMatchObject({ + linked: true, + cloudUserId: "user-123", + managedTunnelActive: false, + }); + expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false); + }).pipe(provideReleaseHarness({ store: failingStore, applyConfigCalls, requests })); + }); }); diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 6f8f9429b9050c0f4d9cfcf27c74e1da10266b78..26846edd99d90cf7ddd63f8654812d37866fce4c 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -816,8 +816,17 @@ const make = Effect.gen(function* () { }, }); } + const endpointRuntimeJson = payload.endpointRuntime + ? yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime) + : null; yield* endpointRuntime.applyConfig(null); yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + // The relay has already committed this link. The stored runtime config + // is what reports the link as managed and what startup respawns, so drop + // it first and store it last. A write that fails part-way then leaves a + // link that reads as not managed, and the next relink repairs it instead + // of trusting a stale tunnel config. + yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); yield* secrets.set( @@ -831,11 +840,8 @@ const make = Effect.gen(function* () { ); yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey)); yield* awarenessRelay.requestCatchUp(); - if (payload.endpointRuntime) { - const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); + if (endpointRuntimeJson !== null) { yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson)); - } else { - yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG); } if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) { return { -- 2.54.0