authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 04:08:07-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 04:08:07-07:00
logf5b5f9dd7be29c4f55f4687dda15e4105cf966fc
treee166f4d437e3548e34be0367449bb52fe1feaa55
parent9688e74112af93147a19499260dfaaa3f2517e0f
signature Signed by PGP key B5690EEEBB952194

fix(server): a failed T3 Connect relink no longer leaves a stale managed tunnel (#19383)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 66 insertions(+), 4 deletions(-)

apps/server/src/cloud/CloudLink.lifecycle.test.ts+56
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38 decodeConfirmedOrigin,38 decodeConfirmedOrigin,
39 decodeRuntimeConfig,39 decodeRuntimeConfig,
40 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,40 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
41 RELAY_ISSUER_SECRET,
41 RELAY_URL_SECRET,42 RELAY_URL_SECRET,
42} from "./config.ts";43} from "./config.ts";
43import * as CloudLink from "./CloudLink.ts";44import * as CloudLink from "./CloudLink.ts";
...@@ -1158,4 +1159,59 @@ describe("releaseManagedTunnelOnShutdown", () => {...@@ -1158,4 +1159,59 @@ describe("releaseManagedTunnelOnShutdown", () => {
1158 }),1159 }),
1159 );1160 );
1160 });1161 });
1162
1163 // The relay commits the link before the server stores it (#11898). A write
1164 // that fails part-way must not leave the stale tunnel config that reports
1165 // the link as managed, or Settings never relinks.
1166 it.effect("does not report a managed tunnel after a relay config write fails part-way", () => {
1167 const { store, values } = makeMemorySecretStore([
1168 [CLOUD_ENDPOINT_RUNTIME_CONFIG, "managed-config"],
1169 [CLOUD_ENDPOINT_CONFIRMED_ORIGIN, "confirmed-origin"],
1170 [RELAY_URL_SECRET, "https://relay.example.test"],
1171 [CLOUD_LINKED_USER_ID, "user-123"],
1172 [RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "old-credential"],
1173 ]);
1174 const failingStore: ServerSecretStore.ServerSecretStore["Service"] = {
1175 ...store,
1176 set: (name, value) =>
1177 name === RELAY_ISSUER_SECRET
1178 ? Effect.fail(
1179 new ServerSecretStore.SecretStorePersistError({
1180 resource: name,
1181 cause: PlatformError.systemError({
1182 _tag: "PermissionDenied",
1183 module: "FileSystem",
1184 method: "rename",
1185 pathOrDescriptor: `${name}.bin`,
1186 }),
1187 }),
1188 )
1189 : store.set(name, value),
1190 };
1191 const applyConfigCalls: Array<unknown> = [];
1192 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
1193 const cloudMintPublicKey =
1194 "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAMY09Vo5CzEA6aEkhB/0h27t4Qs71SHS1fTSI4K0KvZQ=\n-----END PUBLIC KEY-----\n";
1195
1196 return Effect.gen(function* () {
1197 const link = yield* CloudLink.CloudLink;
1198 const error = yield* Effect.flip(
1199 link.applyRelayConfig({
1200 relayUrl: "https://relay.example.test",
1201 cloudUserId: "user-123",
1202 environmentCredential: "new-credential",
1203 cloudMintPublicKey,
1204 endpointRuntime: null,
1205 }),
1206 );
1207
1208 expect(error._tag).toBe("CloudLinkInternalError");
1209 expect(yield* link.linkState()).toMatchObject({
1210 linked: true,
1211 cloudUserId: "user-123",
1212 managedTunnelActive: false,
1213 });
1214 expect(values.has(CLOUD_ENDPOINT_RUNTIME_CONFIG)).toBe(false);
1215 }).pipe(provideReleaseHarness({ store: failingStore, applyConfigCalls, requests }));
1216 });
1161});1217});
apps/server/src/cloud/CloudLink.ts+10-4
...@@ -816,8 +816,17 @@ const make = Effect.gen(function* () {...@@ -816,8 +816,17 @@ const make = Effect.gen(function* () {
816 },816 },
817 });817 });
818 }818 }
819 const endpointRuntimeJson = payload.endpointRuntime
820 ? yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime)
821 : null;
819 yield* endpointRuntime.applyConfig(null);822 yield* endpointRuntime.applyConfig(null);
820 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);823 yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN);
824 // The relay has already committed this link. The stored runtime config
825 // is what reports the link as managed and what startup respawns, so drop
826 // it first and store it last. A write that fails part-way then leaves a
827 // link that reads as not managed, and the next relink repairs it instead
828 // of trusting a stale tunnel config.
829 yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
821830
822 yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl));831 yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl));
823 yield* secrets.set(832 yield* secrets.set(
...@@ -831,11 +840,8 @@ const make = Effect.gen(function* () {...@@ -831,11 +840,8 @@ const make = Effect.gen(function* () {
831 );840 );
832 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));841 yield* secrets.set(CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey));
833 yield* awarenessRelay.requestCatchUp();842 yield* awarenessRelay.requestCatchUp();
834 if (payload.endpointRuntime) {843 if (endpointRuntimeJson !== null) {
835 const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime);
836 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson));844 yield* secrets.set(CLOUD_ENDPOINT_RUNTIME_CONFIG, stringToBytes(endpointRuntimeJson));
837 } else {
838 yield* secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG);
839 }845 }
840 if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) {846 if (payload.endpointRuntime === null || options?.confirmedOrigin === undefined) {
841 return {847 return {