From 9cfcc06fecf6468306d27b5d4bf0ed06cb3c149a Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Tue, 15 Sep 2026 12:18:58 -0300 Subject: [PATCH 1/2] fix(connect): repair drifted managed links --- .../src/cloud/useCloudLinkController.test.tsx | 207 ++++++++++++++++++ apps/web/src/cloud/useCloudLinkController.ts | 26 ++- .../settings/ConnectionsSettings.tsx | 49 +++-- docs/user/remote-access.md | 15 +- .../src/relay/errorPresentation.test.ts | 17 +- .../src/relay/errorPresentation.ts | 3 + 6 files changed, 292 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/cloud/useCloudLinkController.test.tsx diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx new file mode 100644 index 000000000000..a03f27fafe32 --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -0,0 +1,207 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, useLayoutEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getToken: vi.fn(), + link: vi.fn(), + unlink: vi.fn(), + preferences: vi.fn(), + refresh: vi.fn(), + refreshRelay: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@clerk/react", () => ({ + useAuth: () => ({ getToken: mocks.getToken, isSignedIn: true }), +})); +vi.mock("../components/ui/toast", () => ({ toastManager: { add: mocks.toast } })); +vi.mock("../state/relay", () => ({ relayEnvironmentDiscovery: { refresh: mocks.refreshRelay } })); +vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); +vi.mock("./linkEnvironmentAtoms", () => ({ + linkPrimaryEnvironment: mocks.link, + unlinkPrimaryEnvironment: mocks.unlink, + updatePrimaryEnvironmentPreferences: mocks.preferences, +})); +vi.mock("./primaryCloudLinkState", () => ({ usePrimaryCloudLinkState: () => linkState })); +vi.mock("../state/environments", () => ({ useRelayEnvironmentDiscovery: () => discovery })); +vi.mock("./publicConfig", () => ({ resolveRelayClerkTokenOptions: () => ({}) })); + +import { useCloudLinkController } from "./useCloudLinkController"; + +const target = { + environmentId: EnvironmentId.make("test-environment"), + label: "Test environment", + httpBaseUrl: "http://localhost:1234", + wsBaseUrl: "ws://localhost:1234/ws", +}; +const linkState = { + target, + data: { linked: true, managedTunnelActive: true, publishAgentActivity: true }, + refresh: mocks.refresh, + error: null, + isPending: false, +}; +let discovery: Discovery.RelayEnvironmentDiscoveryState; +let renderer: ReactTestRenderer | undefined; +let controller: ReturnType; + +function Harness() { + const current = useCloudLinkController(); + useLayoutEffect(() => { + controller = current; + }); + return null; +} + +beforeEach(async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + linkState.data = { linked: true, managedTunnelActive: true, publishAgentActivity: true }; + discovery = { + environments: new Map([ + [ + target.environmentId, + { + environment: { + environmentId: target.environmentId, + label: target.label, + endpoint: { + httpBaseUrl: target.httpBaseUrl, + wsBaseUrl: target.wsBaseUrl, + providerKind: "manual", + }, + linkedAt: "2026-09-15T12:00:00.000Z", + }, + availability: "online", + status: Option.none(), + error: Option.none(), + }, + ], + ]), + refreshing: false, + offline: false, + error: Option.none(), + }; + mocks.getToken.mockResolvedValue("test-token"); + for (const command of [mocks.link, mocks.unlink, mocks.preferences, mocks.refreshRelay]) { + command.mockResolvedValue(AsyncResult.success(undefined)); + } + await act(async () => { + renderer = create(); + }); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("relinks a publish-only relay record even when local T3 Connect is already on", async () => { + expect(controller.managedTunnelOutOfSync).toBe(true); + await act(async () => { + expect(await controller.reconcileCloudState({ managedTunnel: true, publish: true })).toBe(true); + }); + expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" }); + expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: true }); + expect(mocks.refreshRelay).toHaveBeenCalledOnce(); +}); + +it.each(["healthy", "unknown"])( + "keeps publish changes cheap when the relay mode is %s", + async (state) => { + discovery = { + ...discovery, + environments: + state === "unknown" + ? new Map() + : new Map( + [...discovery.environments].map(([id, entry]) => [ + id, + { + ...entry, + environment: { + ...entry.environment, + endpoint: { ...entry.environment.endpoint, providerKind: "cloudflare_tunnel" }, + }, + }, + ]), + ), + }; + await act(async () => renderer?.update()); + expect(controller.managedTunnelOutOfSync).toBe(false); + await act(async () => { + expect(await controller.reconcileCloudState({ managedTunnel: true, publish: false })).toBe( + true, + ); + }); + expect(mocks.link).not.toHaveBeenCalled(); + expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false }); + }, +); + +it("allows an explicit repair without discovery and preserves disabled publishing", async () => { + discovery = { ...discovery, environments: new Map() }; + linkState.data.publishAgentActivity = false; + await act(async () => renderer?.update()); + await act(async () => { + expect( + await controller.reconcileCloudState( + { managedTunnel: true, publish: false }, + { forceRelink: true }, + ), + ).toBe(true); + }); + expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" }); + expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false }); +}); + +it("keeps publishing on when disabling a managed tunnel", async () => { + await act(async () => { + expect(await controller.reconcileCloudState({ managedTunnel: false, publish: true })).toBe( + true, + ); + }); + expect(mocks.link).toHaveBeenCalledWith({ + target, + clerkToken: "test-token", + mode: "publish_only", + }); + expect(mocks.unlink).not.toHaveBeenCalled(); + expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: true }); +}); + +it("fully unlinks when both capabilities are off, even with relay drift", async () => { + await act(async () => { + expect(await controller.reconcileCloudState({ managedTunnel: false, publish: false })).toBe( + true, + ); + }); + expect(mocks.unlink).toHaveBeenCalledWith({ target, clerkToken: "test-token" }); + expect(mocks.link).not.toHaveBeenCalled(); +}); + +it("reports a failed repair without changing publishing and refreshes local state", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.link.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("Relay unavailable")))); + try { + await act(async () => { + expect( + await controller.reconcileCloudState( + { managedTunnel: true, publish: true }, + { forceRelink: true }, + ), + ).toBe(false); + }); + expect(controller.operationError).toBe("Relay unavailable"); + expect(mocks.preferences).not.toHaveBeenCalled(); + expect(mocks.refresh).toHaveBeenCalledOnce(); + } finally { + consoleError.mockRestore(); + } +}); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index d91596880850..415a9d1a91e1 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -8,6 +8,7 @@ import { import { useState } from "react"; import { toastManager } from "../components/ui/toast"; +import { useRelayEnvironmentDiscovery } from "../state/environments"; import { relayEnvironmentDiscovery } from "../state/relay"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -29,8 +30,8 @@ export interface CloudLinkDesiredState { * a single relay link, so consumers express the full desired state and * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise * (re)link with the mode the managed-tunnel bit implies and set the publish - * preference. Re-linking only happens when the managed-tunnel mode actually - * changes, so flipping publish alone is cheap. + * preference. Re-linking happens when the mode changes, discovery detects + * drift, or the user requests a repair. Publishing-only changes stay cheap. */ export function useCloudLinkController() { const { getToken, isSignedIn } = useAuth(); @@ -48,6 +49,7 @@ export function useCloudLinkController() { { reportFailure: false }, ); const primaryCloudLinkState = usePrimaryCloudLinkState(); + const relayDiscovery = useRelayEnvironmentDiscovery(); const [operationError, setOperationError] = useState(null); const reportUpdateFailure = (cause: unknown) => { @@ -76,8 +78,18 @@ export function useCloudLinkController() { primaryCloudLinkState.data?.managedTunnelActive ?? primaryCloudLinkState.data?.linked ?? false; const publishAgentActivity = primaryCloudLinkState.data?.publishAgentActivity ?? false; const linked = primaryCloudLinkState.data?.linked ?? false; + const relayEnvironment = primaryCloudLinkState.target + ? relayDiscovery.environments.get(primaryCloudLinkState.target.environmentId)?.environment + : undefined; + const managedTunnelOutOfSync = + linked && + relayEnvironment !== undefined && + managedTunnelActive !== (relayEnvironment.endpoint.providerKind === "cloudflare_tunnel"); - const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { + const reconcileCloudState = async ( + desired: CloudLinkDesiredState, + { forceRelink = false }: { readonly forceRelink?: boolean } = {}, + ): Promise => { setOperationError(null); const target = primaryCloudLinkState.target; if (!target) { @@ -115,7 +127,12 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); return false; } - if (!linked || managedTunnelActive !== desired.managedTunnel) { + if ( + forceRelink || + !linked || + managedTunnelOutOfSync || + managedTunnelActive !== desired.managedTunnel + ) { const linkResult = await linkPrimaryEnvironment({ target, clerkToken, @@ -156,6 +173,7 @@ export function useCloudLinkController() { linkState: primaryCloudLinkState, linked, managedTunnelActive, + managedTunnelOutOfSync, publishAgentActivity, operationError, reconcileCloudState, diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index f76e6ef10d64..0e2d735212df 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1660,7 +1660,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b const { isSignedIn, linkState: primaryCloudLinkState, + linked, managedTunnelActive, + managedTunnelOutOfSync, publishAgentActivity, operationError, reconcileCloudState, @@ -1674,10 +1676,14 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b ? "Your session does not have permission to manage T3 Connect access." : null; const isBusy = isUpdating || isUpdatingPreference; + const isDisabled = !canManageRelay || !isSignedIn || primaryCloudLinkState.isPending || isBusy; - const updateManagedTunnel = async (enabled: boolean) => { + const updateManagedTunnel = async (enabled: boolean, forceRelink = false) => { setIsUpdating(true); - const ok = await reconcileCloudState({ managedTunnel: enabled, publish: publishAgentActivity }); + const ok = await reconcileCloudState( + { managedTunnel: enabled, publish: publishAgentActivity }, + { forceRelink }, + ); if (ok) { // Turning the tunnel off while publishing stays on downgrades the link // rather than removing it — say so instead of claiming an unlink. @@ -1691,7 +1697,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b description: enabled ? "This environment is available through T3 Connect." : publishAgentActivity - ? "The managed tunnel was removed. Agent activity publishing stays on." + ? "This link now publishes activity only. Turn off Publish agent activity to fully unlink it." : "This environment is no longer available through T3 Connect.", }); } @@ -1719,18 +1725,35 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b void updateManagedTunnel(enabled)} - /> +
+ {linked ? ( + + ) : null} + void updateManagedTunnel(enabled)} + /> +
} /> ) : null} @@ -1741,7 +1764,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b void updatePublishAgentActivity(enabled)} /> diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ade332337187..fc86c668a5d4 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -173,13 +173,14 @@ configuration. It is not a live reachability check. If the environment appears offline, run `t3 service status` and read the displayed log. If it disappears when SSH closes, see [background-service troubleshooting](./background-service.md#troubleshooting). -| Error | Recovery | -| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `environment_link_limit_exceeded` or managed tunnel limit | Deregister an unused environment, then restart T3 Code on the host. | -| `auth_invalid` or `invalid_bearer` | Run `t3 connect login`. If credentials were revoked, run `t3 connect logout`, then `t3 connect` again. Restart the server after signing in. | -| Expired or invalid link proof | Check the host's date and time, update T3 Code, then restart it. | -| HTTP 403 without a recognized error | Check relay access, proxies, and firewall rules. Keep any Cloudflare Ray ID for a bug report. | -| HTTP 408, 429, or 5xx | Check network and relay availability. Startup retries temporary failures for up to ten minutes. | +| Error | Recovery | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `endpoint_provider_not_managed` or T3 Connect is out of sync | In the host desktop app, open **Settings → Connections** and choose **Repair** next to T3 Connect. Activity publishing stays as configured. For a command-line host, run `t3 connect` again. | +| `environment_link_limit_exceeded` or managed tunnel limit | Deregister an unused environment, then restart T3 Code on the host. | +| `auth_invalid` or `invalid_bearer` | Run `t3 connect login`. If credentials were revoked, run `t3 connect logout`, then `t3 connect` again. Restart the server after signing in. | +| Expired or invalid link proof | Check the host's date and time, update T3 Code, then restart it. | +| HTTP 403 without a recognized error | Check relay access, proxies, and firewall rules. Keep any Cloudflare Ray ID for a bug report. | +| HTTP 408, 429, or 5xx | Check network and relay availability. Startup retries temporary failures for up to ten minutes. | After fixing a permanent rejection, restart the host's server. On Linux, use `systemctl --user restart t3code.service` for the background service. For a diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts index a27810c4366e..8be0b9ce8a6a 100644 --- a/packages/client-runtime/src/relay/errorPresentation.test.ts +++ b/packages/client-runtime/src/relay/errorPresentation.test.ts @@ -1,4 +1,7 @@ -import { RelayAuthInvalidError } from "@t3tools/contracts/relay"; +import { + RelayAuthInvalidError, + RelayEnvironmentConnectNotAuthorizedError, +} from "@t3tools/contracts/relay"; import { describe, expect, it } from "@effect/vitest"; import { @@ -9,6 +12,18 @@ import { } from "./errorPresentation.ts"; describe("relayProtectedErrorMessage", () => { + it("directs publish-only connection failures to the host's T3 Connect settings", () => { + const error = new RelayEnvironmentConnectNotAuthorizedError({ + code: "environment_connect_not_authorized", + reason: "endpoint_provider_not_managed", + traceId: "trace-1", + }); + + expect(relayProtectedErrorMessage(error)).toBe( + "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect.", + ); + }); + it("presents clock skew as one possible cause when the relay omits the reason", () => { const error = new RelayAuthInvalidError({ code: "auth_invalid", diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index b9a53e5ce8b8..854337fa2b8f 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -43,6 +43,9 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { if (error.reason === "environment_link_not_found") { return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; } + if (error.reason === "endpoint_provider_not_managed") { + return "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect."; + } return error.reason ? `Relay rejected the environment connection request (${error.reason}).` : "Relay rejected the environment connection request."; From 7db4055fcf1e9ead0a4c8d9cf4cf03d1d14f4880 Mon Sep 17 00:00:00 2001 From: Guillermo Casanova Date: Tue, 15 Sep 2026 12:43:41 -0300 Subject: [PATCH 2/2] fix(connect): handle refresh gaps and CLI recovery --- apps/web/src/cloud/useCloudLinkController.test.tsx | 12 ++++++++++++ apps/web/src/cloud/useCloudLinkController.ts | 3 ++- .../src/relay/errorPresentation.test.ts | 4 ++-- .../client-runtime/src/relay/errorPresentation.ts | 2 +- 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx index a03f27fafe32..1be6eb8156b7 100644 --- a/apps/web/src/cloud/useCloudLinkController.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -112,6 +112,18 @@ it("relinks a publish-only relay record even when local T3 Connect is already on expect(mocks.refreshRelay).toHaveBeenCalledOnce(); }); +it("relinks when discovery temporarily clears the drifted record during refresh", async () => { + discovery = { ...discovery, environments: new Map(), refreshing: true }; + await act(async () => renderer?.update()); + await act(async () => { + expect(await controller.reconcileCloudState({ managedTunnel: true, publish: false })).toBe( + true, + ); + }); + expect(mocks.link).toHaveBeenCalledWith({ target, clerkToken: "test-token", mode: "managed" }); + expect(mocks.preferences).toHaveBeenCalledWith({ target, publishAgentActivity: false }); +}); + it.each(["healthy", "unknown"])( "keeps publish changes cheap when the relay mode is %s", async (state) => { diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index 415a9d1a91e1..e673f3e3f39e 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -31,7 +31,7 @@ export interface CloudLinkDesiredState { * `reconcileCloudState` applies it: unlink when neither is wanted, otherwise * (re)link with the mode the managed-tunnel bit implies and set the publish * preference. Re-linking happens when the mode changes, discovery detects - * drift, or the user requests a repair. Publishing-only changes stay cheap. + * drift or is refreshing an unknown mode, or the user requests a repair. */ export function useCloudLinkController() { const { getToken, isSignedIn } = useAuth(); @@ -130,6 +130,7 @@ export function useCloudLinkController() { if ( forceRelink || !linked || + (relayDiscovery.refreshing && relayEnvironment === undefined) || managedTunnelOutOfSync || managedTunnelActive !== desired.managedTunnel ) { diff --git a/packages/client-runtime/src/relay/errorPresentation.test.ts b/packages/client-runtime/src/relay/errorPresentation.test.ts index 8be0b9ce8a6a..c1ba174135be 100644 --- a/packages/client-runtime/src/relay/errorPresentation.test.ts +++ b/packages/client-runtime/src/relay/errorPresentation.test.ts @@ -12,7 +12,7 @@ import { } from "./errorPresentation.ts"; describe("relayProtectedErrorMessage", () => { - it("directs publish-only connection failures to the host's T3 Connect settings", () => { + it("provides desktop and command-line recovery for publish-only connection failures", () => { const error = new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", reason: "endpoint_provider_not_managed", @@ -20,7 +20,7 @@ describe("relayProtectedErrorMessage", () => { }); expect(relayProtectedErrorMessage(error)).toBe( - "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect.", + "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect. For a command-line host, run t3 connect again.", ); }); diff --git a/packages/client-runtime/src/relay/errorPresentation.ts b/packages/client-runtime/src/relay/errorPresentation.ts index 854337fa2b8f..dcdeeff31dab 100644 --- a/packages/client-runtime/src/relay/errorPresentation.ts +++ b/packages/client-runtime/src/relay/errorPresentation.ts @@ -44,7 +44,7 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string { return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; } if (error.reason === "endpoint_provider_not_managed") { - return "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect."; + return "This environment is linked for activity publishing only. In the host desktop app, open Settings > Connections and enable or repair T3 Connect. For a command-line host, run t3 connect again."; } return error.reason ? `Relay rejected the environment connection request (${error.reason}).`