diff --git a/apps/web/src/cloud/useCloudLinkController.test.tsx b/apps/web/src/cloud/useCloudLinkController.test.tsx new file mode 100644 index 000000000000..1be6eb8156b7 --- /dev/null +++ b/apps/web/src/cloud/useCloudLinkController.test.tsx @@ -0,0 +1,219 @@ +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("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) => { + 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..e673f3e3f39e 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 is refreshing an unknown mode, or the user requests a repair. */ 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,13 @@ export function useCloudLinkController() { reportUpdateFailure(new Error("Sign in to T3 Connect before enabling this.")); return false; } - if (!linked || managedTunnelActive !== desired.managedTunnel) { + if ( + forceRelink || + !linked || + (relayDiscovery.refreshing && relayEnvironment === undefined) || + managedTunnelOutOfSync || + managedTunnelActive !== desired.managedTunnel + ) { const linkResult = await linkPrimaryEnvironment({ target, clerkToken, @@ -156,6 +174,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..c1ba174135be 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("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", + 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. For a command-line host, run t3 connect again.", + ); + }); + 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..dcdeeff31dab 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. For a command-line host, run t3 connect again."; + } return error.reason ? `Relay rejected the environment connection request (${error.reason}).` : "Relay rejected the environment connection request.";