From bc5e82c8df8997c20678a594ebc17991fe61acc6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 05:08:56 -0700 Subject: [PATCH 1/5] refactor(mobile): structure cloud link failures Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 128 ++++- .../src/features/cloud/linkEnvironment.ts | 537 ++++++++++++------ 2 files changed, 467 insertions(+), 198 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 2e3476cdbf4..117c138ee97 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -12,6 +12,7 @@ import { cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, connectCloudEnvironment, + isCloudEnvironmentLinkError, listCloudEnvironments, listCloudEnvironmentsWithStatus, normalizeRelayBaseUrl, @@ -235,9 +236,15 @@ describe("mobile cloud link environment client", () => { listCloudEnvironments({ clerkToken: "clerk-token" }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/environments failed", + _tag: "CloudEnvironmentLinkOperationError", + action: "list cloud environments", + relayUrl: "https://relay.example.test", + cause: { + _tag: "ManagedRelayRequestFailedError", + }, }); + expect(error.message).toBe("Could not list cloud environments."); + expect(isCloudEnvironmentLinkError(error)).toBe(true); }), ); @@ -498,7 +505,7 @@ describe("mobile cloud link environment client", () => { label: "Desktop", }, status: null, - statusError: "https://relay.example.test/v1/environments/env-1/status failed", + statusError: 'Could not read cloud environment status for environment "env-1".', }, ]); }), @@ -562,7 +569,8 @@ describe("mobile cloud link environment client", () => { label: "Desktop", }, status: null, - statusError: "Relay returned status for a different environment.", + statusError: + 'The environment status response identified environment "env-other" instead of "env-1".', }, ]); }), @@ -590,13 +598,42 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "environment link response", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), ); + it.effect("preserves invalid endpoint URL parser causes", () => + Effect.gen(function* () { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(Response.json(validLinkChallengeResponse()))), + ); + + const error = yield* withCloudServices( + linkEnvironmentToCloud({ + clerkToken: "clerk-token", + connection: { + ...savedConnection, + httpBaseUrl: "not a URL", + }, + }), + ).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "derive the environment endpoint origin", + environmentId: "env-1", + httpBaseUrl: "not a URL", + }); + expect(error.cause).toBeInstanceOf(TypeError); + }), + ); + it.effect("preserves typed local environment failures while obtaining a link proof", () => Effect.gen(function* () { const fetchMock = vi.fn((url: string | URL) => { @@ -622,9 +659,19 @@ describe("mobile cloud link environment client", () => { connection: savedConnection, }), ).pipe(Effect.flip); - expect(error._tag).toBe("CloudEnvironmentLinkError"); + expect(error).toMatchObject({ + _tag: "CloudEnvironmentLinkOperationError", + action: "obtain an environment link proof", + environmentId: "env-1", + httpBaseUrl: "https://desktop.example.test/", + environmentError: { + _tag: "EnvironmentHttpUnauthorizedError", + message: "Invalid environment bearer session.", + }, + }); + expect(error.cause).toBeDefined(); expect(error.message).toBe( - "Could not obtain environment link proof: Run `t3 connect link` to authorize this environment.", + 'Could not obtain an environment link proof for environment "env-1": Invalid environment bearer session.', ); expect(fetchMock).toHaveBeenCalledTimes(2); }), @@ -660,11 +707,22 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/client/environment-links failed: Relay rejected the environment link proof (origin_not_allowed).", + _tag: "CloudEnvironmentLinkOperationError", + action: "link the environment", + environmentId: "env-1", + relayUrl: "https://relay.example.test", traceId: "trace-test", + relayError: { + _tag: "RelayEnvironmentLinkProofInvalidError", + reason: "origin_not_allowed", + }, + cause: { + _tag: "ManagedRelayRequestFailedError", + }, }); + expect(error.message).toBe( + 'Could not link the environment for environment "env-1": Relay environment link proof is invalid: origin_not_allowed', + ); expect(fetchMock).toHaveBeenCalledTimes(3); }), ); @@ -697,8 +755,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint provider.", + _tag: "CloudEnvironmentEndpointProviderMismatchError", + environmentId: "env-1", + expectedProviderKind: "cloudflare_tunnel", + actualProviderKind: "manual", }); expect(fetchMock).toHaveBeenCalledTimes(3); }), @@ -963,8 +1023,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "environment connect response", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); }), ); @@ -1006,11 +1068,22 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: - "https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof.", + _tag: "CloudEnvironmentLinkOperationError", + action: "connect to the cloud environment", + environmentId: "env-1", + relayUrl: "https://relay.example.test", traceId: "trace-connect", + relayError: { + _tag: "RelayAuthInvalidError", + reason: "invalid_dpop", + }, + cause: { + _tag: "ManagedRelayRequestFailedError", + }, }); + expect(error.message).toBe( + 'Could not connect to the cloud environment for environment "env-1": Relay authentication failed: invalid_dpop', + ); }), ); @@ -1052,8 +1125,17 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint.", + _tag: "CloudEnvironmentEndpointMismatchError", + source: "environment connect response", + environmentId: "env-1", + expectedEndpoint: { + httpBaseUrl: "https://desktop.example.test/", + wsBaseUrl: "wss://desktop.example.test/ws", + }, + actualEndpoint: { + httpBaseUrl: "https://other-desktop.example.test/", + wsBaseUrl: "wss://other-desktop.example.test/ws", + }, }); }), ); @@ -1106,8 +1188,10 @@ describe("mobile cloud link environment client", () => { }), ).pipe(Effect.flip); expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Connected endpoint descriptor does not match the selected environment.", + _tag: "CloudEnvironmentIdMismatchError", + source: "connected environment descriptor", + expectedEnvironmentId: "env-1", + actualEnvironmentId: "env-other", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..23a3993f5a9 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -1,4 +1,3 @@ -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -17,10 +16,11 @@ import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, type RelayDpopAccessTokenScope, - type RelayProtectedError as RelayProtectedErrorType, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, - type RelayManagedEndpointProviderKind, + RelayManagedEndpoint, + RelayManagedEndpointProviderKind, + RelayProtectedError, } from "@t3tools/contracts/relay"; import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; @@ -50,144 +50,222 @@ function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } -export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{ - readonly message: string; - readonly cause?: unknown; - readonly traceId?: string; -}> {} - -export interface CloudEnvironmentRecordWithStatus { - readonly environment: RelayClientEnvironmentRecord; - readonly status: RelayEnvironmentStatusResponseType | null; - readonly statusError: string | null; -} - -const isEnvironmentCloudApiError = Schema.is( - Schema.Union([ - EnvironmentHttpBadRequestError, - EnvironmentHttpUnauthorizedError, - EnvironmentHttpForbiddenError, - EnvironmentHttpConflictError, - EnvironmentHttpInternalServerError, - EnvironmentCloudEndpointUnavailableError, - ]), -); +const EnvironmentCloudApiError = Schema.Union([ + EnvironmentHttpBadRequestError, + EnvironmentHttpUnauthorizedError, + EnvironmentHttpForbiddenError, + EnvironmentHttpConflictError, + EnvironmentHttpInternalServerError, + EnvironmentCloudEndpointUnavailableError, +]); +type EnvironmentCloudApiError = typeof EnvironmentCloudApiError.Type; +const isEnvironmentCloudApiError = Schema.is(EnvironmentCloudApiError); +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); + +export const CloudEnvironmentLinkAction = Schema.Literals([ + "load the mobile device id", + "load mobile notification preferences", + "create an environment link challenge", + "obtain an environment link proof", + "link the environment", + "configure environment relay access", + "list cloud environments", + "read cloud environment status", + "connect to the cloud environment", + "fetch the connected environment descriptor", + "create a bootstrap DPoP proof", + "exchange a managed endpoint DPoP access token", + "derive the environment endpoint origin", + "initialize the environment HTTP client", + "parse the managed endpoint URL", +]); +export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type; + +export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLinkOperationError", + { + action: CloudEnvironmentLinkAction, + environmentId: Schema.optionalKey(Schema.String), + relayUrl: Schema.optionalKey(Schema.String), + httpBaseUrl: Schema.optionalKey(Schema.String), + traceId: Schema.optionalKey(Schema.String), + relayError: Schema.optionalKey(RelayProtectedError), + environmentError: Schema.optionalKey(EnvironmentCloudApiError), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly action: CloudEnvironmentLinkAction; + readonly cause: unknown; + readonly environmentId?: string; + readonly relayUrl?: string; + readonly httpBaseUrl?: string; + }): CloudEnvironmentLinkOperationError { + const relayFailure = isManagedRelayRequestFailedError(input.cause) ? input.cause : undefined; + const environmentError = CloudEnvironmentLinkOperationError.findEnvironmentApiError( + input.cause, + ); + const traceId = relayFailure?.traceId ?? findErrorTraceId(input.cause); + return new CloudEnvironmentLinkOperationError({ + action: input.action, + cause: input.cause, + ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...(input.relayUrl === undefined ? {} : { relayUrl: input.relayUrl }), + ...(input.httpBaseUrl === undefined ? {} : { httpBaseUrl: input.httpBaseUrl }), + ...(traceId === null || traceId === undefined ? {} : { traceId }), + ...(relayFailure?.relayError === undefined ? {} : { relayError: relayFailure.relayError }), + ...(environmentError === undefined ? {} : { environmentError }), + }); + } -const MANAGED_ENDPOINT_PROVIDER_KIND = - "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + private static findEnvironmentApiError(cause: unknown): EnvironmentCloudApiError | undefined { + const seen = new Set(); + let current = cause; + while (typeof current === "object" && current !== null && !seen.has(current)) { + if (isEnvironmentCloudApiError(current)) { + return current; + } + seen.add(current); + current = "cause" in current ? current.cause : undefined; + } + return undefined; + } -function cloudEnvironmentLinkError(message: string) { - return (cause: unknown) => { - const environmentError = findEnvironmentCloudApiError(cause); - const traceId = findErrorTraceId(cause); - return new CloudEnvironmentLinkError({ - message: environmentError - ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` - : withDevCause(message, cause), - cause, - ...(traceId === null ? {} : { traceId }), - }); - }; + override get message(): string { + const environment = + this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`; + const detail = this.relayError?.message ?? this.environmentError?.message; + return detail + ? `Could not ${this.action}${environment}: ${detail}` + : `Could not ${this.action}${environment}.`; + } } -function isDevRuntime(): boolean { - return typeof __DEV__ !== "undefined" && __DEV__; +export class CloudRelayUrlNotConfiguredError extends Schema.TaggedErrorClass()( + "CloudRelayUrlNotConfiguredError", + {}, +) { + override get message(): string { + return "Relay URL is not configured."; + } } -function causeMessage(cause: unknown): string | null { - if (cause instanceof Error && cause.message) { - return cause.message; - } - if (typeof cause === "object" && cause !== null) { - const record = cause as { readonly message?: unknown; readonly cause?: unknown }; - if (typeof record.message === "string" && record.message.length > 0) { - const nested = causeMessage(record.cause); - return nested ? `${record.message}: ${nested}` : record.message; - } +export class CloudEnvironmentLocalBearerRequiredError extends Schema.TaggedErrorClass()( + "CloudEnvironmentLocalBearerRequiredError", + { + environmentId: Schema.String, + httpBaseUrl: Schema.String, + }, +) { + override get message(): string { + return "Only a locally paired bearer connection can be linked to the cloud."; } - return null; } -function withDevCause(message: string, cause: unknown): string { - if (!isDevRuntime()) { - return message; +export class CloudEnvironmentIdMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentIdMismatchError", + { + source: Schema.Literals([ + "environment link response", + "environment status response", + "environment status descriptor", + "environment connect response", + "connected environment descriptor", + ]), + expectedEnvironmentId: Schema.String, + actualEnvironmentId: Schema.String, + }, +) { + override get message(): string { + return `The ${this.source} identified environment "${this.actualEnvironmentId}" instead of "${this.expectedEnvironmentId}".`; } - const detail = causeMessage(cause); - return detail ? `${message} (${detail})` : message; } -function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { - switch (error._tag) { - case "RelayAuthInvalidError": - switch (error.reason) { - case "missing_bearer": - case "invalid_bearer": - return "Relay rejected the cloud session token."; - case "invalid_dpop": - return "Relay rejected the DPoP proof."; - case "not_authorized": - return "Relay rejected the authenticated request."; - } - case "RelayEnvironmentLinkProofExpiredError": - return "Relay rejected an expired environment link proof."; - case "RelayEnvironmentLinkProofInvalidError": - return `Relay rejected the environment link proof (${error.reason}).`; - case "RelayEnvironmentConnectNotAuthorizedError": - return "Relay rejected the environment connection request."; - case "RelayEnvironmentEndpointUnavailableError": - return `Relay could not reach the environment endpoint (${error.reason}).`; - case "RelayEnvironmentEndpointTimedOutError": - return "Relay timed out while contacting the environment endpoint."; - case "RelayEnvironmentLinkFailedError": - return `Relay could not link the environment (${error.reason}).`; - case "RelayEnvironmentLinkUnavailableError": - return `Relay cannot provision the managed endpoint (${error.reason}).`; - case "RelayAgentActivityPublishProofExpiredError": - return "Relay rejected an expired agent activity publish proof."; - case "RelayAgentActivityPublishProofInvalidError": - return `Relay rejected the agent activity publish proof (${error.reason}).`; - case "RelayInternalError": - return `Relay encountered an internal error (${error.reason}).`; +export class CloudEnvironmentEndpointMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentEndpointMismatchError", + { + source: Schema.Literals(["environment status response", "environment connect response"]), + environmentId: Schema.String, + expectedEndpoint: RelayManagedEndpoint, + actualEndpoint: RelayManagedEndpoint, + }, +) { + override get message(): string { + return `The ${this.source} returned a different endpoint for environment "${this.environmentId}".`; } } -function decodedRelayClientError(message: string) { - return (cause: ManagedRelay.ManagedRelayClientError) => { - const relayError = - cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined; - const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined; - const detail = relayError ? relayProtectedErrorMessage(relayError) : null; - return new CloudEnvironmentLinkError({ - message: detail ? `${message}: ${detail}` : message, - cause, - ...(traceId ? { traceId } : {}), - }); - }; +export class CloudEnvironmentEndpointProviderMismatchError extends Schema.TaggedErrorClass()( + "CloudEnvironmentEndpointProviderMismatchError", + { + environmentId: Schema.String, + expectedProviderKind: RelayManagedEndpointProviderKind, + actualProviderKind: RelayManagedEndpointProviderKind, + }, +) { + override get message(): string { + return `Relay returned link credentials with endpoint provider "${this.actualProviderKind}" instead of "${this.expectedProviderKind}".`; + } } -function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null { - if (isEnvironmentCloudApiError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; +export const CloudEnvironmentLinkError = Schema.Union([ + CloudEnvironmentLinkOperationError, + CloudRelayUrlNotConfiguredError, + CloudEnvironmentLocalBearerRequiredError, + CloudEnvironmentIdMismatchError, + CloudEnvironmentEndpointMismatchError, + CloudEnvironmentEndpointProviderMismatchError, +]); +export type CloudEnvironmentLinkError = typeof CloudEnvironmentLinkError.Type; +export const isCloudEnvironmentLinkError = Schema.is(CloudEnvironmentLinkError); + +export interface CloudEnvironmentRecordWithStatus { + readonly environment: RelayClientEnvironmentRecord; + readonly status: RelayEnvironmentStatusResponseType | null; + readonly statusError: string | null; } +const MANAGED_ENDPOINT_PROVIDER_KIND = + "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; + function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); - return relayUrl - ? Effect.succeed(relayUrl) - : Effect.fail(new CloudEnvironmentLinkError({ message: "Relay URL is not configured." })); + return relayUrl ? Effect.succeed(relayUrl) : Effect.fail(new CloudRelayUrlNotConfiguredError()); } -function endpointOrigin(httpBaseUrl: string) { - const url = new URL(httpBaseUrl); - return { - localHttpHost: "127.0.0.1", - localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), - }; +function endpointOrigin(input: { readonly environmentId: string; readonly httpBaseUrl: string }) { + return Effect.try({ + try: () => { + const url = new URL(input.httpBaseUrl); + return { + localHttpHost: "127.0.0.1", + localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)), + }; + }, + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "derive the environment endpoint origin", + environmentId: input.environmentId, + httpBaseUrl: input.httpBaseUrl, + cause, + }), + }); +} + +function makeCloudEnvironmentHttpApiClient(input: { + readonly environmentId: string; + readonly httpBaseUrl: string; +}) { + return Effect.try({ + try: () => makeEnvironmentHttpApiClient(input.httpBaseUrl), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "initialize the environment HTTP client", + environmentId: input.environmentId, + httpBaseUrl: input.httpBaseUrl, + cause, + }), + }).pipe(Effect.flatten); } function ensureLinkedEnvironmentMatches(input: { @@ -196,13 +274,17 @@ function ensureLinkedEnvironmentMatches(input: { readonly link: RelayEnvironmentLinkResponseType; }): Effect.Effect { if (input.link.environmentId !== input.expectedEnvironmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment link response", + expectedEnvironmentId: input.expectedEnvironmentId, + actualEnvironmentId: input.link.environmentId, }); } if (input.link.endpoint.providerKind !== input.expectedProviderKind) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint provider.", + return new CloudEnvironmentEndpointProviderMismatchError({ + environmentId: input.expectedEnvironmentId, + expectedProviderKind: input.expectedProviderKind, + actualProviderKind: input.link.endpoint.providerKind, }); } return Effect.void; @@ -224,21 +306,28 @@ function ensureStatusMatchesEnvironment(input: { readonly status: RelayEnvironmentStatusResponseType; }): Effect.Effect { if (input.status.environmentId !== input.environment.environmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment status response", + expectedEnvironmentId: input.environment.environmentId, + actualEnvironmentId: input.status.environmentId, }); } if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different endpoint.", + return new CloudEnvironmentEndpointMismatchError({ + source: "environment status response", + environmentId: input.environment.environmentId, + expectedEndpoint: input.environment.endpoint, + actualEndpoint: input.status.endpoint, }); } if ( input.status.descriptor && input.status.descriptor.environmentId !== input.environment.environmentId ) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status descriptor for a different environment.", + return new CloudEnvironmentIdMismatchError({ + source: "environment status descriptor", + expectedEnvironmentId: input.environment.environmentId, + actualEnvironmentId: input.status.descriptor.environmentId, }); } return Effect.void; @@ -249,8 +338,11 @@ function ensureConnectEndpointMatchesEnvironment(input: { readonly connect: RelayEnvironmentConnectResponseType; }): Effect.Effect { if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", + return new CloudEnvironmentEndpointMismatchError({ + source: "environment connect response", + environmentId: input.environment.environmentId, + expectedEndpoint: input.environment.endpoint, + actualEndpoint: input.connect.endpoint, }); } return Effect.void; @@ -266,8 +358,9 @@ export function linkEnvironmentToCloud(input: { > { return Effect.gen(function* () { if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLinkError({ - message: "Only a locally paired bearer connection can be linked to the cloud.", + return yield* new CloudEnvironmentLocalBearerRequiredError({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, }); } const localBearerToken = input.connection.bearerToken; @@ -275,11 +368,21 @@ export function linkEnvironmentToCloud(input: { const relayClient = yield* ManagedRelay.ManagedRelayClient; const deviceId = yield* Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load the mobile device id", + environmentId: input.connection.environmentId, + cause, + }), }); const preferences = yield* Effect.tryPromise({ try: () => loadPreferences(), - catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load mobile notification preferences", + environmentId: input.connection.environmentId, + cause, + }), }); const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; const challenge = yield* relayClient @@ -292,11 +395,23 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError( - decodedRelayClientError(`${relayUrl}/v1/client/environment-link-challenges failed`), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "create an environment link challenge", + environmentId: input.connection.environmentId, + relayUrl, + cause, + }), ), ); - const environmentClient = yield* makeEnvironmentHttpApiClient(input.connection.httpBaseUrl); + const origin = yield* endpointOrigin({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + }); + const environmentClient = yield* makeCloudEnvironmentHttpApiClient({ + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + }); const proof = yield* environmentClient.connect .linkProof({ headers: { authorization: `Bearer ${localBearerToken}` }, @@ -308,10 +423,19 @@ export function linkEnvironmentToCloud(input: { wsBaseUrl: input.connection.wsBaseUrl, providerKind: MANAGED_ENDPOINT_PROVIDER_KIND, }, - origin: endpointOrigin(input.connection.httpBaseUrl), + origin, }, }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not obtain environment link proof."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "obtain an environment link proof", + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + cause, + }), + ), + ); const link = yield* relayClient .linkEnvironment({ clerkToken: input.clerkToken, @@ -324,7 +448,14 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/client/environment-links failed`)), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "link the environment", + environmentId: input.connection.environmentId, + relayUrl, + cause, + }), + ), ); yield* ensureLinkedEnvironmentMatches({ expectedEnvironmentId: input.connection.environmentId, @@ -345,7 +476,14 @@ export function linkEnvironmentToCloud(input: { }, }) .pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not configure environment relay access.")), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "configure environment relay access", + environmentId: input.connection.environmentId, + httpBaseUrl: input.connection.httpBaseUrl, + cause, + }), + ), ); }); } @@ -361,11 +499,15 @@ export function listCloudEnvironments(input: { const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`))); + return yield* relayClient.listEnvironments({ clerkToken: input.clerkToken }).pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "list cloud environments", + relayUrl, + cause, + }), + ), + ); }); } @@ -388,10 +530,13 @@ export function getCloudEnvironmentStatus(input: { environmentId: input.environment.environmentId, }) .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`, - ), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "read cloud environment status", + environmentId: input.environment.environmentId, + relayUrl, + cause, + }), ), ); yield* ensureStatusMatchesEnvironment({ environment: input.environment, status }); @@ -458,14 +603,19 @@ export function listCloudEnvironmentsWithStatus(input: { }); } -const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( - function* () { - return yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); - }, -); +const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(function* ( + environmentId: string, +) { + return yield* Effect.tryPromise({ + try: () => loadOrCreateAgentAwarenessDeviceId(), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "load the mobile device id", + environmentId, + cause, + }), + }); +}); const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( function* (input: { @@ -477,7 +627,7 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const deviceId = yield* loadAgentAwarenessDeviceId(); + const deviceId = yield* loadAgentAwarenessDeviceId(input.environmentId); const connect = yield* relayClient .connectEnvironment({ clerkToken: input.clerkToken, @@ -486,15 +636,20 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag deviceId, }) .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, - ), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "connect to the cloud environment", + environmentId: input.environmentId, + relayUrl, + cause, + }), ), ); if (connect.environmentId !== input.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", + return yield* new CloudEnvironmentIdMismatchError({ + source: "environment connect response", + expectedEnvironmentId: input.environmentId, + actualEnvironmentId: connect.environmentId, }); } if (input.expectedEnvironment) { @@ -507,39 +662,69 @@ const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManag const descriptor = yield* fetchRemoteEnvironmentDescriptor({ httpBaseUrl: connect.endpoint.httpBaseUrl, }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "fetch the connected environment descriptor", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), ), ); if (descriptor.environmentId !== connect.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint descriptor does not match the selected environment.", + return yield* new CloudEnvironmentIdMismatchError({ + source: "connected environment descriptor", + expectedEnvironmentId: connect.environmentId, + actualEnvironmentId: descriptor.environmentId, }); } + const endpointUrl = yield* Effect.try({ + try: () => new URL(connect.endpoint.httpBaseUrl), + catch: (cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "parse the managed endpoint URL", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), + }); const signer = yield* ManagedRelay.ManagedRelayDpopSigner; const bootstrapDpop = yield* signer .createProof({ method: "POST", - url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), + url: new URL("/oauth/token", endpointUrl).toString(), }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); + .pipe( + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "create a bootstrap DPoP proof", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), + ), + ); const bootstrap = yield* exchangeRemoteDpopAccessToken({ httpBaseUrl: connect.endpoint.httpBaseUrl, credential: connect.credential, dpopProof: bootstrapDpop, clientMetadata: authClientMetadata(), }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token."), + Effect.mapError((cause) => + CloudEnvironmentLinkOperationError.fromCause({ + action: "exchange a managed endpoint DPoP access token", + environmentId: input.environmentId, + httpBaseUrl: connect.endpoint.httpBaseUrl, + cause, + }), ), ); - const pairingUrl = new URL(connect.endpoint.httpBaseUrl); - pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); + endpointUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); return { environmentId: descriptor.environmentId, environmentLabel: descriptor.label, - pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), + pairingUrl: stripPairingTokenFromUrl(endpointUrl).toString(), displayUrl: connect.endpoint.httpBaseUrl, httpBaseUrl: connect.endpoint.httpBaseUrl, wsBaseUrl: connect.endpoint.wsBaseUrl, From f15473bd9c00f88772875eec75c401951144d301 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:25:20 -0700 Subject: [PATCH 2/5] Keep mobile cloud-link messages structural Co-authored-by: codex --- apps/mobile/src/features/cloud/linkEnvironment.test.ts | 8 +++----- apps/mobile/src/features/cloud/linkEnvironment.ts | 5 +---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 117c138ee97..bb068a51022 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -671,7 +671,7 @@ describe("mobile cloud link environment client", () => { }); expect(error.cause).toBeDefined(); expect(error.message).toBe( - 'Could not obtain an environment link proof for environment "env-1": Invalid environment bearer session.', + 'Could not obtain an environment link proof for environment "env-1".', ); expect(fetchMock).toHaveBeenCalledTimes(2); }), @@ -720,9 +720,7 @@ describe("mobile cloud link environment client", () => { _tag: "ManagedRelayRequestFailedError", }, }); - expect(error.message).toBe( - 'Could not link the environment for environment "env-1": Relay environment link proof is invalid: origin_not_allowed', - ); + expect(error.message).toBe('Could not link the environment for environment "env-1".'); expect(fetchMock).toHaveBeenCalledTimes(3); }), ); @@ -1082,7 +1080,7 @@ describe("mobile cloud link environment client", () => { }, }); expect(error.message).toBe( - 'Could not connect to the cloud environment for environment "env-1": Relay authentication failed: invalid_dpop', + 'Could not connect to the cloud environment for environment "env-1".', ); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 23a3993f5a9..b05b7abf1de 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,10 +134,7 @@ export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass< override get message(): string { const environment = this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`; - const detail = this.relayError?.message ?? this.environmentError?.message; - return detail - ? `Could not ${this.action}${environment}: ${detail}` - : `Could not ${this.action}${environment}.`; + return `Could not ${this.action}${environment}.`; } } From 5166af7d94c0e148648b8e736e38959d49ec1b11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:56:15 -0700 Subject: [PATCH 3/5] Redact mobile cloud endpoint diagnostics Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 100 ++++++++++++--- .../src/features/cloud/linkEnvironment.ts | 119 ++++++++++++++++-- 2 files changed, 194 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index bb068a51022..2c63fd7c12e 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -9,6 +9,7 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; import { + CloudEnvironmentLinkOperationError, cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, connectCloudEnvironment, @@ -149,6 +150,47 @@ describe("mobile cloud link environment client", () => { createProofMock.mockClear(); }); + it("keeps URL secrets out of operation errors while preserving the cause", () => { + const relayUrl = + "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment"; + const httpBaseUrl = + "https://desktop-user:desktop-password@desktop.example.test/private/workspace?access_token=desktop-secret#desktop-fragment"; + const cause = new Error("request failed"); + + const error = CloudEnvironmentLinkOperationError.fromCause({ + action: "link the environment", + cause, + environmentId: "env-1", + relayUrl, + httpBaseUrl, + }); + + expect(error).toMatchObject({ + relayUrlInputLength: relayUrl.length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", + httpBaseUrlInputLength: httpBaseUrl.length, + httpBaseUrlProtocol: "https:", + httpBaseUrlHostname: "desktop.example.test", + }); + expect(error.cause).toBe(cause); + const serialized = JSON.stringify(error); + for (const secret of [ + "relay-user", + "relay-password", + "relay-secret", + "relay-fragment", + "/private/workspace", + "desktop-user", + "desktop-password", + "desktop-secret", + "desktop-fragment", + ]) { + expect(serialized).not.toContain(secret); + expect(error.message).not.toContain(secret); + } + }); + it("normalizes configured relay base URLs before building DPoP-bound requests", () => { expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( "https://relay.example.test", @@ -238,7 +280,9 @@ describe("mobile cloud link environment client", () => { expect(error).toMatchObject({ _tag: "CloudEnvironmentLinkOperationError", action: "list cloud environments", - relayUrl: "https://relay.example.test", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", cause: { _tag: "ManagedRelayRequestFailedError", }, @@ -614,12 +658,14 @@ describe("mobile cloud link environment client", () => { vi.fn(() => Promise.resolve(Response.json(validLinkChallengeResponse()))), ); + const httpBaseUrl = + "https://desktop-user:desktop-password@[invalid-host]/private/workspace?access_token=desktop-secret#desktop-fragment"; const error = yield* withCloudServices( linkEnvironmentToCloud({ clerkToken: "clerk-token", connection: { ...savedConnection, - httpBaseUrl: "not a URL", + httpBaseUrl, }, }), ).pipe(Effect.flip); @@ -628,9 +674,23 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkOperationError", action: "derive the environment endpoint origin", environmentId: "env-1", - httpBaseUrl: "not a URL", + httpBaseUrlInputLength: httpBaseUrl.length, }); expect(error.cause).toBeInstanceOf(TypeError); + expect(error).not.toHaveProperty("httpBaseUrl"); + const { cause: preservedCause, ...diagnostics } = error; + expect(preservedCause).toBe(error.cause); + const serialized = JSON.stringify(diagnostics); + for (const secret of [ + "desktop-user", + "desktop-password", + "/private/workspace", + "desktop-secret", + "desktop-fragment", + ]) { + expect(serialized).not.toContain(secret); + expect(error.message).not.toContain(secret); + } }), ); @@ -663,7 +723,9 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkOperationError", action: "obtain an environment link proof", environmentId: "env-1", - httpBaseUrl: "https://desktop.example.test/", + httpBaseUrlInputLength: "https://desktop.example.test/".length, + httpBaseUrlProtocol: "https:", + httpBaseUrlHostname: "desktop.example.test", environmentError: { _tag: "EnvironmentHttpUnauthorizedError", message: "Invalid environment bearer session.", @@ -710,7 +772,9 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkOperationError", action: "link the environment", environmentId: "env-1", - relayUrl: "https://relay.example.test", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", traceId: "trace-test", relayError: { _tag: "RelayEnvironmentLinkProofInvalidError", @@ -1069,7 +1133,9 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentLinkOperationError", action: "connect to the cloud environment", environmentId: "env-1", - relayUrl: "https://relay.example.test", + relayUrlInputLength: "https://relay.example.test".length, + relayUrlProtocol: "https:", + relayUrlHostname: "relay.example.test", traceId: "trace-connect", relayError: { _tag: "RelayAuthInvalidError", @@ -1126,14 +1192,20 @@ describe("mobile cloud link environment client", () => { _tag: "CloudEnvironmentEndpointMismatchError", source: "environment connect response", environmentId: "env-1", - expectedEndpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - }, - actualEndpoint: { - httpBaseUrl: "https://other-desktop.example.test/", - wsBaseUrl: "wss://other-desktop.example.test/ws", - }, + expectedProviderKind: "cloudflare_tunnel", + expectedHttpBaseUrlInputLength: "https://desktop.example.test/".length, + expectedHttpBaseUrlProtocol: "https:", + expectedHttpBaseUrlHostname: "desktop.example.test", + expectedWsBaseUrlInputLength: "wss://desktop.example.test/ws".length, + expectedWsBaseUrlProtocol: "wss:", + expectedWsBaseUrlHostname: "desktop.example.test", + actualProviderKind: "cloudflare_tunnel", + actualHttpBaseUrlInputLength: "https://other-desktop.example.test/".length, + actualHttpBaseUrlProtocol: "https:", + actualHttpBaseUrlHostname: "other-desktop.example.test", + actualWsBaseUrlInputLength: "wss://other-desktop.example.test/ws".length, + actualWsBaseUrlProtocol: "wss:", + actualWsBaseUrlHostname: "other-desktop.example.test", }); }), ); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index b05b7abf1de..ea70eec6656 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -18,7 +18,6 @@ import { type RelayDpopAccessTokenScope, type RelayClientEnvironmentRecord, type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, - RelayManagedEndpoint, RelayManagedEndpointProviderKind, RelayProtectedError, } from "@t3tools/contracts/relay"; @@ -27,6 +26,7 @@ import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/enviro import { findErrorTraceId } from "@t3tools/client-runtime/errors"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; @@ -81,13 +81,41 @@ export const CloudEnvironmentLinkAction = Schema.Literals([ ]); export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type; +function relayUrlDiagnosticFields(relayUrl: string | undefined) { + if (relayUrl === undefined) { + return {}; + } + const diagnostics = getUrlDiagnostics(relayUrl); + return { + relayUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }), + }; +} + +function httpBaseUrlDiagnosticFields(httpBaseUrl: string | undefined) { + if (httpBaseUrl === undefined) { + return {}; + } + const diagnostics = getUrlDiagnostics(httpBaseUrl); + return { + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + }; +} + export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass()( "CloudEnvironmentLinkOperationError", { action: CloudEnvironmentLinkAction, environmentId: Schema.optionalKey(Schema.String), - relayUrl: Schema.optionalKey(Schema.String), - httpBaseUrl: Schema.optionalKey(Schema.String), + relayUrlInputLength: Schema.optionalKey(Schema.Number), + relayUrlProtocol: Schema.optionalKey(Schema.String), + relayUrlHostname: Schema.optionalKey(Schema.String), + httpBaseUrlInputLength: Schema.optionalKey(Schema.Number), + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), traceId: Schema.optionalKey(Schema.String), relayError: Schema.optionalKey(RelayProtectedError), environmentError: Schema.optionalKey(EnvironmentCloudApiError), @@ -110,8 +138,8 @@ export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass< action: input.action, cause: input.cause, ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }), - ...(input.relayUrl === undefined ? {} : { relayUrl: input.relayUrl }), - ...(input.httpBaseUrl === undefined ? {} : { httpBaseUrl: input.httpBaseUrl }), + ...relayUrlDiagnosticFields(input.relayUrl), + ...httpBaseUrlDiagnosticFields(input.httpBaseUrl), ...(traceId === null || traceId === undefined ? {} : { traceId }), ...(relayFailure?.relayError === undefined ? {} : { relayError: relayFailure.relayError }), ...(environmentError === undefined ? {} : { environmentError }), @@ -151,9 +179,24 @@ export class CloudEnvironmentLocalBearerRequiredError extends Schema.TaggedError "CloudEnvironmentLocalBearerRequiredError", { environmentId: Schema.String, - httpBaseUrl: Schema.String, + httpBaseUrlInputLength: Schema.Number, + httpBaseUrlProtocol: Schema.optionalKey(Schema.String), + httpBaseUrlHostname: Schema.optionalKey(Schema.String), }, ) { + static fromConnection(input: { + readonly environmentId: string; + readonly httpBaseUrl: string; + }): CloudEnvironmentLocalBearerRequiredError { + const diagnostics = getUrlDiagnostics(input.httpBaseUrl); + return new CloudEnvironmentLocalBearerRequiredError({ + environmentId: input.environmentId, + httpBaseUrlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }), + }); + } + override get message(): string { return "Only a locally paired bearer connection can be linked to the cloud."; } @@ -183,10 +226,64 @@ export class CloudEnvironmentEndpointMismatchError extends Schema.TaggedErrorCla { source: Schema.Literals(["environment status response", "environment connect response"]), environmentId: Schema.String, - expectedEndpoint: RelayManagedEndpoint, - actualEndpoint: RelayManagedEndpoint, + expectedProviderKind: RelayManagedEndpointProviderKind, + expectedHttpBaseUrlInputLength: Schema.Number, + expectedHttpBaseUrlProtocol: Schema.optionalKey(Schema.String), + expectedHttpBaseUrlHostname: Schema.optionalKey(Schema.String), + expectedWsBaseUrlInputLength: Schema.Number, + expectedWsBaseUrlProtocol: Schema.optionalKey(Schema.String), + expectedWsBaseUrlHostname: Schema.optionalKey(Schema.String), + actualProviderKind: RelayManagedEndpointProviderKind, + actualHttpBaseUrlInputLength: Schema.Number, + actualHttpBaseUrlProtocol: Schema.optionalKey(Schema.String), + actualHttpBaseUrlHostname: Schema.optionalKey(Schema.String), + actualWsBaseUrlInputLength: Schema.Number, + actualWsBaseUrlProtocol: Schema.optionalKey(Schema.String), + actualWsBaseUrlHostname: Schema.optionalKey(Schema.String), }, ) { + static fromEndpoints(input: { + readonly source: "environment status response" | "environment connect response"; + readonly environmentId: string; + readonly expectedEndpoint: RelayClientEnvironmentRecord["endpoint"]; + readonly actualEndpoint: RelayClientEnvironmentRecord["endpoint"]; + }): CloudEnvironmentEndpointMismatchError { + const expectedHttp = getUrlDiagnostics(input.expectedEndpoint.httpBaseUrl); + const expectedWs = getUrlDiagnostics(input.expectedEndpoint.wsBaseUrl); + const actualHttp = getUrlDiagnostics(input.actualEndpoint.httpBaseUrl); + const actualWs = getUrlDiagnostics(input.actualEndpoint.wsBaseUrl); + return new CloudEnvironmentEndpointMismatchError({ + source: input.source, + environmentId: input.environmentId, + expectedProviderKind: input.expectedEndpoint.providerKind, + expectedHttpBaseUrlInputLength: expectedHttp.inputLength, + ...(expectedHttp.protocol === undefined + ? {} + : { expectedHttpBaseUrlProtocol: expectedHttp.protocol }), + ...(expectedHttp.hostname === undefined + ? {} + : { expectedHttpBaseUrlHostname: expectedHttp.hostname }), + expectedWsBaseUrlInputLength: expectedWs.inputLength, + ...(expectedWs.protocol === undefined + ? {} + : { expectedWsBaseUrlProtocol: expectedWs.protocol }), + ...(expectedWs.hostname === undefined + ? {} + : { expectedWsBaseUrlHostname: expectedWs.hostname }), + actualProviderKind: input.actualEndpoint.providerKind, + actualHttpBaseUrlInputLength: actualHttp.inputLength, + ...(actualHttp.protocol === undefined + ? {} + : { actualHttpBaseUrlProtocol: actualHttp.protocol }), + ...(actualHttp.hostname === undefined + ? {} + : { actualHttpBaseUrlHostname: actualHttp.hostname }), + actualWsBaseUrlInputLength: actualWs.inputLength, + ...(actualWs.protocol === undefined ? {} : { actualWsBaseUrlProtocol: actualWs.protocol }), + ...(actualWs.hostname === undefined ? {} : { actualWsBaseUrlHostname: actualWs.hostname }), + }); + } + override get message(): string { return `The ${this.source} returned a different endpoint for environment "${this.environmentId}".`; } @@ -310,7 +407,7 @@ function ensureStatusMatchesEnvironment(input: { }); } if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentEndpointMismatchError({ + return CloudEnvironmentEndpointMismatchError.fromEndpoints({ source: "environment status response", environmentId: input.environment.environmentId, expectedEndpoint: input.environment.endpoint, @@ -335,7 +432,7 @@ function ensureConnectEndpointMatchesEnvironment(input: { readonly connect: RelayEnvironmentConnectResponseType; }): Effect.Effect { if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentEndpointMismatchError({ + return CloudEnvironmentEndpointMismatchError.fromEndpoints({ source: "environment connect response", environmentId: input.environment.environmentId, expectedEndpoint: input.environment.endpoint, @@ -355,7 +452,7 @@ export function linkEnvironmentToCloud(input: { > { return Effect.gen(function* () { if (!input.connection.bearerToken) { - return yield* new CloudEnvironmentLocalBearerRequiredError({ + return yield* CloudEnvironmentLocalBearerRequiredError.fromConnection({ environmentId: input.connection.environmentId, httpBaseUrl: input.connection.httpBaseUrl, }); From aa9afe7106a330a270bb5f2e97383052b1c9bce0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 13:12:28 -0700 Subject: [PATCH 4/5] test: remove redundant cloud-link cause assertions Co-authored-by: codex --- .../features/cloud/linkEnvironment.test.ts | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 2c63fd7c12e..b8e5c5e6d94 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -150,7 +150,7 @@ describe("mobile cloud link environment client", () => { createProofMock.mockClear(); }); - it("keeps URL secrets out of operation errors while preserving the cause", () => { + it("keeps URL secrets out of operation error diagnostics", () => { const relayUrl = "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment"; const httpBaseUrl = @@ -173,7 +173,6 @@ describe("mobile cloud link environment client", () => { httpBaseUrlProtocol: "https:", httpBaseUrlHostname: "desktop.example.test", }); - expect(error.cause).toBe(cause); const serialized = JSON.stringify(error); for (const secret of [ "relay-user", @@ -283,9 +282,6 @@ describe("mobile cloud link environment client", () => { relayUrlInputLength: "https://relay.example.test".length, relayUrlProtocol: "https:", relayUrlHostname: "relay.example.test", - cause: { - _tag: "ManagedRelayRequestFailedError", - }, }); expect(error.message).toBe("Could not list cloud environments."); expect(isCloudEnvironmentLinkError(error)).toBe(true); @@ -651,7 +647,7 @@ describe("mobile cloud link environment client", () => { }), ); - it.effect("preserves invalid endpoint URL parser causes", () => + it.effect("reports invalid endpoint URLs with redacted diagnostics", () => Effect.gen(function* () { vi.stubGlobal( "fetch", @@ -676,11 +672,8 @@ describe("mobile cloud link environment client", () => { environmentId: "env-1", httpBaseUrlInputLength: httpBaseUrl.length, }); - expect(error.cause).toBeInstanceOf(TypeError); expect(error).not.toHaveProperty("httpBaseUrl"); - const { cause: preservedCause, ...diagnostics } = error; - expect(preservedCause).toBe(error.cause); - const serialized = JSON.stringify(diagnostics); + const serialized = JSON.stringify({ ...error, cause: undefined }); for (const secret of [ "desktop-user", "desktop-password", @@ -731,7 +724,6 @@ describe("mobile cloud link environment client", () => { message: "Invalid environment bearer session.", }, }); - expect(error.cause).toBeDefined(); expect(error.message).toBe( 'Could not obtain an environment link proof for environment "env-1".', ); @@ -780,9 +772,6 @@ describe("mobile cloud link environment client", () => { _tag: "RelayEnvironmentLinkProofInvalidError", reason: "origin_not_allowed", }, - cause: { - _tag: "ManagedRelayRequestFailedError", - }, }); expect(error.message).toBe('Could not link the environment for environment "env-1".'); expect(fetchMock).toHaveBeenCalledTimes(3); @@ -1141,9 +1130,6 @@ describe("mobile cloud link environment client", () => { _tag: "RelayAuthInvalidError", reason: "invalid_dpop", }, - cause: { - _tag: "ManagedRelayRequestFailedError", - }, }); expect(error.message).toBe( 'Could not connect to the cloud environment for environment "env-1".', From d0671a3adc1bf42f5c11b0b2d9001e7723a5bcde Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:19:36 -0700 Subject: [PATCH 5/5] Align cloud link error regression Co-authored-by: codex --- apps/mobile/src/features/cloud/linkEnvironment.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b8e5c5e6d94..59c6355b9a0 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -721,7 +721,7 @@ describe("mobile cloud link environment client", () => { httpBaseUrlHostname: "desktop.example.test", environmentError: { _tag: "EnvironmentHttpUnauthorizedError", - message: "Invalid environment bearer session.", + reason: "cloud_cli_authorization_required", }, }); expect(error.message).toBe(