History

[codex] Structure web cloud-link failures (#3316)

Julius Marminge committed 4 months ago

Commit 65ed5d91 parent 252622a2 files · +674 −163Browse files

Unified · Split

2 files
  1. apps/web/src/cloud/linkEnvironment.test.ts +251 −3
  2. apps/web/src/cloud/linkEnvironment.ts +423 −160
apps/web/src/cloud/linkEnvironment.test.ts+251−3
@@ -2,12 +2,14 @@ import { · 1 unmodified line
22 type DesktopBridge,
33 EnvironmentId,
44 type RelayClientInstallProgressEvent,
5+ type RelayClientStatus,
56 WS_METHODS,
67 } from "@t3tools/contracts";
78 import { RelayWebClientId } from "@t3tools/contracts/relay";
89 import { describe, expect, it } from "@effect/vitest";
910 import * as Effect from "effect/Effect";
1011 import * as Layer from "effect/Layer";
12+import * as Logger from "effect/Logger";
1113 import * as Option from "effect/Option";
1214 import * as Stream from "effect/Stream";
1315 import * as SubscriptionRef from "effect/SubscriptionRef";
@@ -26,7 +28,9 @@ import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; · 12 unmodified lines
2628 import { __resetDesktopPrimaryAuthForTests } from "../environments/primary/desktopAuth";
2729
2830 import {
31+ CloudEnvironmentLinkOperationError,
2932 collectCloudLinkTargets,
33+ isCloudEnvironmentLinkError,
3034 linkPrimaryEnvironmentToCloud,
3135 listManagedCloudEnvironments,
3236 normalizeRelayBaseUrl,
@@ -48,12 +52,21 @@ const relayClientInstallDialog = vi.hoisted(() => ({ · 15 unmodified lines
4852 finish: vi.fn(),
4953 }));
5054
55+const cloudPublicConfig = vi.hoisted(() => ({
56+ relayUrl: "https://relay.example.test",
57+}));
58+
5159 vi.mock("./relayClientInstallDialog", () => ({
5260 requestRelayClientInstallConfirmation: relayClientInstallDialog.requestConfirmation,
5361 reportRelayClientInstallProgress: relayClientInstallDialog.reportProgress,
5462 finishRelayClientInstall: relayClientInstallDialog.finish,
5563 }));
5664
65+vi.mock("./publicConfig", async (importOriginal) => ({
66+ ...(await importOriginal<typeof import("./publicConfig")>()),
67+ resolveCloudPublicConfig: () => ({ relayUrl: cloudPublicConfig.relayUrl }),
68+}));
69+
5770 const createProof = vi.fn(() => Effect.succeed("dpop-proof"));
5871 const dpopSignerLayer = Layer.succeed(
5972 ManagedRelay.ManagedRelayDpopSigner,
@@ -75,7 +88,7 @@ function relayLayer() { · 15 unmodified lines
7588 }
7689
7790 function registryLayer(options?: {
78− readonly status?: { readonly status: "available"; readonly version: string };
91+ readonly status?: RelayClientStatus;
7992 readonly installEvents?: ReadonlyArray<RelayClientInstallProgressEvent>;
8093 }) {
8194 return Layer.effect(
@@ -83,7 +96,14 @@ function registryLayer(options?: { · 1 unmodified line
8396 Effect.gen(function* () {
8497 const client = {
8598 [WS_METHODS.cloudGetRelayClientStatus]: () =>
86− Effect.succeed(options?.status ?? { status: "available", version: "2026.6.0" }),
99+ Effect.succeed(
100+ options?.status ?? {
101+ status: "available",
102+ executablePath: "/managed/cloudflared",
103+ source: "managed",
104+ version: "2026.6.0",
105+ },
106+ ),
87107 [WS_METHODS.cloudInstallRelayClient]: () =>
88108 Stream.fromIterable(options?.installEvents ?? []),
89109 } as unknown as RpcSession["client"];
@@ -141,6 +161,7 @@ function bodyText(body: BodyInit | null | undefined): string { · 51 unmodified lines
141161
142162 beforeEach(() => {
143163 vi.clearAllMocks();
164+ cloudPublicConfig.relayUrl = "https://relay.example.test";
144165 vi.stubEnv("VITE_T3CODE_RELAY_URL", "https://relay.example.test");
145166 relayClientInstallDialog.requestConfirmation.mockResolvedValue(true);
146167 });
@@ -195,6 +216,47 @@ describe("web cloud link environment client", () => { · 48 unmodified lines
195216 }),
196217 );
197218
219+ it.effect("preserves structured relay failures and trace IDs", () =>
220+ Effect.gen(function* () {
221+ vi.stubGlobal(
222+ "fetch",
223+ vi.fn().mockResolvedValue(
224+ Response.json(
225+ {
226+ _tag: "RelayAuthInvalidError",
227+ code: "auth_invalid",
228+ reason: "invalid_bearer",
229+ traceId: "trace-web-cloud-link",
230+ },
231+ { status: 401 },
232+ ),
233+ ),
234+ );
235+
236+ const error = yield* withServices(
237+ listManagedCloudEnvironments({ clerkToken: "clerk-token" }),
238+ ).pipe(Effect.flip);
239+
240+ expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError);
241+ expect(error).toMatchObject({
242+ action: "list relay-managed environments",
243+ relayUrlInputLength: "https://relay.example.test".length,
244+ relayUrlProtocol: "https:",
245+ relayUrlHostname: "relay.example.test",
246+ traceId: "trace-web-cloud-link",
247+ relayError: {
248+ _tag: "RelayAuthInvalidError",
249+ reason: "invalid_bearer",
250+ },
251+ cause: {
252+ _tag: "ManagedRelayRequestFailedError",
253+ },
254+ });
255+ expect(error.message).toBe("Could not list relay-managed environments.");
256+ expect(isCloudEnvironmentLinkError(error)).toBe(true);
257+ }),
258+ );
259+
198260 it.effect("reads primary cloud link state from the explicit target", () =>
199261 Effect.gen(function* () {
200262 const fetchMock = vi.fn().mockResolvedValue(
@@ -225,6 +287,96 @@ describe("web cloud link environment client", () => { · 24 unmodified lines
225287 }),
226288 );
227289
290+ it.effect("preserves structured environment API failures and their cause chain", () =>
291+ Effect.gen(function* () {
292+ vi.stubGlobal(
293+ "fetch",
294+ vi.fn().mockResolvedValue(
295+ Response.json(
296+ {
297+ _tag: "EnvironmentHttpUnauthorizedError",
298+ message: "Environment bearer token is invalid.",
299+ },
300+ { status: 401 },
301+ ),
302+ ),
303+ );
304+
305+ const error = yield* withServices(readPrimaryCloudLinkState({ target: TARGET })).pipe(
306+ Effect.flip,
307+ );
308+
309+ expect(error).toBeInstanceOf(CloudEnvironmentLinkOperationError);
310+ expect(error).toMatchObject({
311+ action: "read environment cloud link state",
312+ environmentId: TARGET.environmentId,
313+ httpBaseUrlInputLength: TARGET.httpBaseUrl.length,
314+ httpBaseUrlProtocol: "http:",
315+ httpBaseUrlHostname: "127.0.0.1",
316+ environmentError: {
317+ _tag: "EnvironmentHttpUnauthorizedError",
318+ message: "Environment bearer token is invalid.",
319+ },
320+ });
321+ expect(error.cause).toBeDefined();
322+ expect(error.message).toBe(
323+ `Could not read environment cloud link state for environment "${TARGET.environmentId}".`,
324+ );
325+ expect(isCloudEnvironmentLinkError(error)).toBe(true);
326+ }),
327+ );
328+
329+ it.effect("preserves invalid environment HTTP URL parser causes", () =>
330+ Effect.gen(function* () {
331+ const invalidUrl =
332+ "https://user:password@[invalid-host]/private/path?access_token=secret#fragment";
333+ const error = yield* withServices(
334+ readPrimaryCloudLinkState({
335+ target: {
336+ ...TARGET,
337+ httpBaseUrl: invalidUrl,
338+ },
339+ }),
340+ ).pipe(Effect.flip);
341+
342+ expect(error).toMatchObject({
343+ _tag: "CloudEnvironmentLinkOperationError",
344+ action: "initialize the environment HTTP client",
345+ environmentId: TARGET.environmentId,
346+ httpBaseUrlInputLength: invalidUrl.length,
347+ });
348+ expect(error.cause).toBeInstanceOf(TypeError);
349+ expect(error).not.toHaveProperty("httpBaseUrl");
350+ expect(error).not.toHaveProperty("httpBaseUrlProtocol");
351+ expect(error).not.toHaveProperty("httpBaseUrlHostname");
352+ expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
353+ }),
354+ );
355+
356+ it("keeps environment endpoint secrets out of mapped error attributes", () => {
357+ const httpBaseUrl =
358+ "https://user:password@environment.example.test/private/path?access_token=secret#fragment";
359+ const cause = new Error("request failed");
360+ const error = CloudEnvironmentLinkOperationError.fromEnvironmentApi({
361+ action: "read environment cloud link state",
362+ environmentId: TARGET.environmentId,
363+ httpBaseUrl,
364+ cause,
365+ });
366+
367+ expect(error).toMatchObject({
368+ httpBaseUrlInputLength: httpBaseUrl.length,
369+ httpBaseUrlProtocol: "https:",
370+ httpBaseUrlHostname: "environment.example.test",
371+ cause,
372+ });
373+ expect(error.cause).toBe(cause);
374+ expect(error).not.toHaveProperty("httpBaseUrl");
375+ const diagnostics = JSON.stringify(error);
376+ expect(diagnostics).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
377+ expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
378+ });
379+
228380 it.effect("uses desktop bearer auth for primary cloud link state", () =>
229381 Effect.gen(function* () {
230382 const fetchMock = vi.fn().mockResolvedValue(
@@ -306,6 +458,37 @@ describe("web cloud link environment client", () => { · 75 unmodified lines
306458 }),
307459 );
308460
461+ it.effect("validates the environment endpoint before prompting to install a relay client", () =>
462+ Effect.gen(function* () {
463+ const invalidUrl =
464+ "https://user:password@[invalid-host]/private/path?access_token=secret#fragment";
465+
466+ const error = yield* withServices(
467+ linkPrimaryEnvironmentToCloud({
468+ target: {
469+ ...TARGET,
470+ httpBaseUrl: invalidUrl,
471+ },
472+ clerkToken: "clerk-token",
473+ }),
474+ {
475+ status: { status: "missing", version: "2026.6.0" },
476+ },
477+ ).pipe(Effect.flip);
478+
479+ expect(error).toMatchObject({
480+ _tag: "CloudEnvironmentLinkOperationError",
481+ action: "derive the environment endpoint origin",
482+ environmentId: TARGET.environmentId,
483+ httpBaseUrlInputLength: invalidUrl.length,
484+ });
485+ expect(error.cause).toBeInstanceOf(TypeError);
486+ expect(error).not.toHaveProperty("httpBaseUrl");
487+ expect(error.message).not.toMatch(/user|password|private|path|access_token|secret|fragment/);
488+ expect(relayClientInstallDialog.requestConfirmation).not.toHaveBeenCalled();
489+ }),
490+ );
491+
309492 it.effect("installs a missing relay client before linking", () =>
310493 Effect.gen(function* () {
311494 vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ malformed: true })));
@@ -316,7 +499,12 @@ describe("web cloud link environment client", () => { · 4 unmodified lines
316499 clerkToken: "clerk-token",
317500 }),
318501 {
319− status: { status: "available", version: "2026.6.0" },
502+ status: {
503+ status: "available",
504+ executablePath: "/managed/cloudflared",
505+ source: "managed",
506+ version: "2026.6.0",
507+ },
320508 installEvents: [],
321509 },
322510 ).pipe(Effect.flip);
@@ -348,4 +536,64 @@ describe("web cloud link environment client", () => { · 25 unmodified lines
348536 );
349537 }),
350538 );
539+
540+ it.effect("keeps configured relay URL secrets out of revoke warnings", () => {
541+ const relayUrl =
542+ "https://relay-user:relay-password@relay.example.test/private/workspace?access_token=relay-secret#relay-fragment";
543+ cloudPublicConfig.relayUrl = relayUrl;
544+ const capturedLogs: Array<ReadonlyArray<unknown>> = [];
545+ const logger = Logger.make(({ message }) => {
546+ capturedLogs.push(Array.isArray(message) ? message : [message]);
547+ });
548+ const fetchMock = vi
549+ .fn()
550+ .mockResolvedValueOnce(
551+ Response.json({ ok: true, endpointRuntimeStatus: { status: "disabled" } }),
552+ )
553+ .mockResolvedValueOnce(Response.json({ malformed: true }, { status: 500 }));
554+ vi.stubGlobal("fetch", fetchMock);
555+
556+ return unlinkPrimaryEnvironmentFromCloud({
557+ target: TARGET,
558+ clerkToken: "clerk-token",
559+ }).pipe(
560+ Effect.provide(
561+ Layer.merge(
562+ services(),
563+ Logger.layer([logger], {
564+ mergeWithExisting: false,
565+ }),
566+ ),
567+ ),
568+ Effect.tap(() =>
569+ Effect.sync(() => {
570+ expect(capturedLogs).toHaveLength(1);
571+ const logFields = capturedLogs[0]?.find(
572+ (value): value is Record<string, unknown> =>
573+ typeof value === "object" && value !== null,
574+ );
575+ expect(logFields).toMatchObject({
576+ relayUrlInputLength: relayUrl.length,
577+ relayUrlProtocol: "https:",
578+ relayUrlHostname: "relay.example.test",
579+ causeTag: "ManagedRelayRequestFailedError",
580+ });
581+ expect(logFields).not.toHaveProperty("relayUrl");
582+ expect(logFields).not.toHaveProperty("cause");
583+ const logText = capturedLogs[0]
584+ ?.filter((value): value is string => typeof value === "string")
585+ .join(" ");
586+ for (const secret of [
587+ "relay-user",
588+ "relay-password",
589+ "/private/workspace",
590+ "access_token=relay-secret",
591+ "relay-fragment",
592+ ]) {
593+ expect(logText).not.toContain(secret);
594+ }
595+ }),
596+ ),
597+ );
598+ });
351599 });
apps/web/src/cloud/linkEnvironment.ts+423−160
@@ -1,4 +1,3 @@
1−import * as Data from "effect/Data";
21 import * as Effect from "effect/Effect";
32 import * as Option from "effect/Option";
43 import * as Schema from "effect/Schema";
@@ -19,13 +18,14 @@ import { · 14 unmodified lines
1918 type RelayClientDeviceRecord,
2019 type RelayClientEnvironmentRecord,
2120 type RelayEnvironmentLinkResponse,
22− type RelayProtectedError as RelayProtectedErrorType,
2321 type RelayManagedEndpointProviderKind,
22+ RelayProtectedError,
2423 } from "@t3tools/contracts/relay";
2524 import { EnvironmentRegistry } from "@t3tools/client-runtime/connection";
2625 import { request, runStream } from "@t3tools/client-runtime/rpc";
2726 import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
2827 import { ManagedRelay } from "@t3tools/client-runtime/relay";
28+import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics";
2929
3030 import {
3131 readPrimaryEnvironmentDescriptor,
@@ -51,17 +51,218 @@ function relayUrl(): string | null { · 19 unmodified lines
5151 return resolveCloudPublicConfig().relayUrl;
5252 }
5353
54−export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmentLinkError")<{
55− readonly message: string;
56− readonly cause?: unknown;
57− readonly traceId?: string;
58−}> {}
54+const EnvironmentCloudApiError = Schema.Union([
55+ EnvironmentHttpBadRequestError,
56+ EnvironmentHttpUnauthorizedError,
57+ EnvironmentHttpForbiddenError,
58+ EnvironmentHttpConflictError,
59+ EnvironmentHttpInternalServerError,
60+ EnvironmentCloudEndpointUnavailableError,
61+]);
62+type EnvironmentCloudApiError = typeof EnvironmentCloudApiError.Type;
63+const isEnvironmentCloudApiError = Schema.is(EnvironmentCloudApiError);
5964
60−const relayClientRpcError = (message: string) => (cause: unknown) =>
61− new CloudEnvironmentLinkError({
62− message,
63− cause,
64− });
65+function relayUrlDiagnosticFields(relayUrl: string | undefined) {
66+ if (relayUrl === undefined) return {};
67+ const diagnostics = getUrlDiagnostics(relayUrl);
68+ return {
69+ relayUrlInputLength: diagnostics.inputLength,
70+ ...(diagnostics.protocol === undefined ? {} : { relayUrlProtocol: diagnostics.protocol }),
71+ ...(diagnostics.hostname === undefined ? {} : { relayUrlHostname: diagnostics.hostname }),
72+ };
73+}
74+
75+function httpBaseUrlDiagnosticFields(httpBaseUrl: string | undefined) {
76+ if (httpBaseUrl === undefined) return {};
77+ const diagnostics = getUrlDiagnostics(httpBaseUrl);
78+ return {
79+ httpBaseUrlInputLength: diagnostics.inputLength,
80+ ...(diagnostics.protocol === undefined ? {} : { httpBaseUrlProtocol: diagnostics.protocol }),
81+ ...(diagnostics.hostname === undefined ? {} : { httpBaseUrlHostname: diagnostics.hostname }),
82+ };
83+}
84+
85+export const CloudEnvironmentLinkAction = Schema.Literals([
86+ "check relay client availability",
87+ "confirm relay client installation",
88+ "install the relay client",
89+ "list relay-managed environments",
90+ "list cloud devices",
91+ "read environment cloud link state",
92+ "update environment cloud preferences",
93+ "unlink the environment from cloud",
94+ "revoke the cloud environment link",
95+ "create an environment link challenge",
96+ "obtain an environment link proof",
97+ "link the environment",
98+ "derive the environment endpoint origin",
99+ "initialize the environment HTTP client",
100+ "configure environment relay access",
101+]);
102+export type CloudEnvironmentLinkAction = typeof CloudEnvironmentLinkAction.Type;
103+
104+export class CloudEnvironmentLinkOperationError extends Schema.TaggedErrorClass<CloudEnvironmentLinkOperationError>()(
105+ "CloudEnvironmentLinkOperationError",
106+ {
107+ action: CloudEnvironmentLinkAction,
108+ environmentId: Schema.optionalKey(Schema.String),
109+ relayUrlInputLength: Schema.optionalKey(Schema.Number),
110+ relayUrlProtocol: Schema.optionalKey(Schema.String),
111+ relayUrlHostname: Schema.optionalKey(Schema.String),
112+ httpBaseUrlInputLength: Schema.optionalKey(Schema.Number),
113+ httpBaseUrlProtocol: Schema.optionalKey(Schema.String),
114+ httpBaseUrlHostname: Schema.optionalKey(Schema.String),
115+ traceId: Schema.optionalKey(Schema.String),
116+ relayError: Schema.optionalKey(RelayProtectedError),
117+ environmentError: Schema.optionalKey(EnvironmentCloudApiError),
118+ cause: Schema.Defect(),
119+ },
120+) {
121+ static fromManagedRelay(input: {
122+ readonly action: CloudEnvironmentLinkAction;
123+ readonly cause: ManagedRelay.ManagedRelayClientError;
124+ readonly environmentId?: string;
125+ readonly relayUrl?: string;
126+ readonly httpBaseUrl?: string;
127+ }): CloudEnvironmentLinkOperationError {
128+ const requestFailure =
129+ input.cause._tag === "ManagedRelayRequestFailedError" ? input.cause : undefined;
130+ return new CloudEnvironmentLinkOperationError({
131+ action: input.action,
132+ cause: input.cause,
133+ ...(input.environmentId === undefined ? {} : { environmentId: input.environmentId }),
134+ ...relayUrlDiagnosticFields(input.relayUrl),
135+ ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
136+ ...(requestFailure?.traceId === undefined ? {} : { traceId: requestFailure.traceId }),
137+ ...(requestFailure?.relayError === undefined
138+ ? {}
139+ : { relayError: requestFailure.relayError }),
140+ });
141+ }
142+
143+ static fromEnvironmentApi(input: {
144+ readonly action: CloudEnvironmentLinkAction;
145+ readonly cause: unknown;
146+ readonly environmentId: string;
147+ readonly httpBaseUrl: string;
148+ }): CloudEnvironmentLinkOperationError {
149+ const environmentError = CloudEnvironmentLinkOperationError.findEnvironmentApiError(
150+ input.cause,
151+ );
152+ return new CloudEnvironmentLinkOperationError({
153+ action: input.action,
154+ environmentId: input.environmentId,
155+ ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
156+ cause: input.cause,
157+ ...(environmentError === undefined ? {} : { environmentError }),
158+ });
159+ }
160+
161+ private static findEnvironmentApiError(cause: unknown): EnvironmentCloudApiError | undefined {
162+ const seen = new Set<unknown>();
163+ let current = cause;
164+ while (typeof current === "object" && current !== null && !seen.has(current)) {
165+ if (isEnvironmentCloudApiError(current)) {
166+ return current;
167+ }
168+ seen.add(current);
169+ current = "cause" in current ? current.cause : undefined;
170+ }
171+ return undefined;
172+ }
173+
174+ override get message(): string {
175+ const environment =
176+ this.environmentId === undefined ? "" : ` for environment "${this.environmentId}"`;
177+ return `Could not ${this.action}${environment}.`;
178+ }
179+}
180+
181+export class CloudRelayUrlNotConfiguredError extends Schema.TaggedErrorClass<CloudRelayUrlNotConfiguredError>()(
182+ "CloudRelayUrlNotConfiguredError",
183+ { environmentId: Schema.optionalKey(Schema.String) },
184+) {
185+ override get message(): string {
186+ return "T3CODE_RELAY_URL is not configured.";
187+ }
188+}
189+
190+export class CloudRelayClientInstallUnsupportedError extends Schema.TaggedErrorClass<CloudRelayClientInstallUnsupportedError>()(
191+ "CloudRelayClientInstallUnsupportedError",
192+ {
193+ environmentId: Schema.String,
194+ phase: Schema.Literals(["preflight", "post-install"]),
195+ version: Schema.String,
196+ platform: Schema.String,
197+ architecture: Schema.String,
198+ },
199+) {
200+ override get message(): string {
201+ return `T3 Code cannot install the relay client automatically on ${this.platform}-${this.architecture}.`;
202+ }
203+}
204+
205+export class CloudRelayClientInstallCancelledError extends Schema.TaggedErrorClass<CloudRelayClientInstallCancelledError>()(
206+ "CloudRelayClientInstallCancelledError",
207+ {
208+ environmentId: Schema.String,
209+ version: Schema.String,
210+ },
211+) {
212+ override get message(): string {
213+ return "Relay client installation was cancelled.";
214+ }
215+}
216+
217+export class CloudRelayClientInstallIncompleteError extends Schema.TaggedErrorClass<CloudRelayClientInstallIncompleteError>()(
218+ "CloudRelayClientInstallIncompleteError",
219+ {
220+ environmentId: Schema.String,
221+ version: Schema.String,
222+ },
223+) {
224+ override get message(): string {
225+ return "The relay client install completed without a final status.";
226+ }
227+}
228+
229+export class CloudRelayClientUnavailableAfterInstallError extends Schema.TaggedErrorClass<CloudRelayClientUnavailableAfterInstallError>()(
230+ "CloudRelayClientUnavailableAfterInstallError",
231+ {
232+ environmentId: Schema.String,
233+ version: Schema.String,
234+ },
235+) {
236+ override get message(): string {
237+ return "The relay client is still unavailable after installation.";
238+ }
239+}
240+
241+export class CloudEnvironmentLinkResponseMismatchError extends Schema.TaggedErrorClass<CloudEnvironmentLinkResponseMismatchError>()(
242+ "CloudEnvironmentLinkResponseMismatchError",
243+ {
244+ environmentId: Schema.String,
245+ field: Schema.Literals(["environment id", "endpoint provider"]),
246+ expected: Schema.String,
247+ actual: Schema.String,
248+ },
249+) {
250+ override get message(): string {
251+ return `Relay returned link credentials with an unexpected ${this.field}.`;
252+ }
253+}
254+
255+export const CloudEnvironmentLinkError = Schema.Union([
256+ CloudEnvironmentLinkOperationError,
257+ CloudRelayUrlNotConfiguredError,
258+ CloudRelayClientInstallUnsupportedError,
259+ CloudRelayClientInstallCancelledError,
260+ CloudRelayClientInstallIncompleteError,
261+ CloudRelayClientUnavailableAfterInstallError,
262+ CloudEnvironmentLinkResponseMismatchError,
263+]);
264+export type CloudEnvironmentLinkError = typeof CloudEnvironmentLinkError.Type;
265+export const isCloudEnvironmentLinkError = Schema.is(CloudEnvironmentLinkError);
65266
66267 function ensureRelayClientAvailable(
67268 environmentId: EnvironmentId,
@@ -70,21 +271,40 @@ function ensureRelayClientAvailable( · 2 unmodified lines
70271 const registry = yield* EnvironmentRegistry;
71272 const status = yield* registry
72273 .run(environmentId, request(WS_METHODS.cloudGetRelayClientStatus, {}))
73− .pipe(Effect.mapError(relayClientRpcError("Could not check relay client availability.")));
274+ .pipe(
275+ Effect.mapError(
276+ (cause) =>
277+ new CloudEnvironmentLinkOperationError({
278+ action: "check relay client availability",
279+ environmentId,
280+ cause,
281+ }),
282+ ),
283+ );
74284 if (status.status === "available") return;
75285 if (status.status === "unsupported") {
76− return yield* new CloudEnvironmentLinkError({
77− message: `T3 Code cannot install the relay client automatically on ${status.platform}-${status.arch}.`,
286+ return yield* new CloudRelayClientInstallUnsupportedError({
287+ environmentId,
288+ phase: "preflight",
289+ version: status.version,
290+ platform: status.platform,
291+ architecture: status.arch,
78292 });
79293 }
80294
81295 const confirmed = yield* Effect.tryPromise({
82296 try: () => requestRelayClientInstallConfirmation(status.version),
83− catch: relayClientRpcError("Could not confirm relay client installation."),
297+ catch: (cause) =>
298+ new CloudEnvironmentLinkOperationError({
299+ action: "confirm relay client installation",
300+ environmentId,
301+ cause,
302+ }),
84303 });
85304 if (!confirmed) {
86− return yield* new CloudEnvironmentLinkError({
87− message: "Relay client installation was cancelled.",
305+ return yield* new CloudRelayClientInstallCancelledError({
306+ environmentId,
307+ version: status.version,
88308 });
89309 }
90310
@@ -97,112 +317,73 @@ function ensureRelayClientAvailable( · 6 unmodified lines
97317 )
98318 .pipe(
99319 Stream.runLast,
100− Effect.mapError(relayClientRpcError("Could not install the relay client.")),
320+ Effect.mapError(
321+ (cause) =>
322+ new CloudEnvironmentLinkOperationError({
323+ action: "install the relay client",
324+ environmentId,
325+ cause,
326+ }),
327+ ),
101328 Effect.ensuring(Effect.sync(finishRelayClientInstall)),
102329 );
103330 if (Option.isNone(installed) || installed.value.type !== "complete") {
104− return yield* new CloudEnvironmentLinkError({
105− message: "The relay client install completed without a final status.",
331+ return yield* new CloudRelayClientInstallIncompleteError({
332+ environmentId,
333+ version: status.version,
106334 });
107335 }
108336 const installedStatus = installed.value.status;
109337 if (installedStatus.status !== "available") {
110− return yield* new CloudEnvironmentLinkError({
111− message:
112− installedStatus.status === "unsupported"
113− ? `T3 Code cannot install the relay client automatically on ${installedStatus.platform}-${installedStatus.arch}.`
114− : "The relay client is still unavailable after installation.",
115− });
338+ return yield* installedStatus.status === "unsupported"
339+ ? new CloudRelayClientInstallUnsupportedError({
340+ environmentId,
341+ phase: "post-install",
342+ version: installedStatus.version,
343+ platform: installedStatus.platform,
344+ architecture: installedStatus.arch,
345+ })
346+ : new CloudRelayClientUnavailableAfterInstallError({
347+ environmentId,
348+ version: installedStatus.version,
349+ });
116350 }
117351 });
118352 }
119353
120−const isEnvironmentCloudApiError = Schema.is(
121− Schema.Union([
122− EnvironmentHttpBadRequestError,
123− EnvironmentHttpUnauthorizedError,
124− EnvironmentHttpForbiddenError,
125− EnvironmentHttpConflictError,
126− EnvironmentHttpInternalServerError,
127− EnvironmentCloudEndpointUnavailableError,
128− ]),
129−);
130−
131−function relayProtectedErrorMessage(error: RelayProtectedErrorType): string {
132− switch (error._tag) {
133− case "RelayAuthInvalidError":
134− switch (error.reason) {
135− case "missing_bearer":
136− case "invalid_bearer":
137− return "Relay rejected the cloud session token.";
138− case "invalid_dpop":
139− return "Relay rejected the DPoP proof.";
140− case "not_authorized":
141− return "Relay rejected the authenticated request.";
142− }
143− case "RelayEnvironmentLinkProofExpiredError":
144− return "Relay rejected an expired environment link proof.";
145− case "RelayEnvironmentLinkProofInvalidError":
146− return `Relay rejected the environment link proof (${error.reason}).`;
147− case "RelayEnvironmentConnectNotAuthorizedError":
148− return "Relay rejected the environment connection request.";
149− case "RelayEnvironmentEndpointUnavailableError":
150− return `Relay could not reach the environment endpoint (${error.reason}).`;
151− case "RelayEnvironmentEndpointTimedOutError":
152− return "Relay timed out while contacting the environment endpoint.";
153− case "RelayEnvironmentLinkFailedError":
154− return `Relay could not link the environment (${error.reason}).`;
155− case "RelayEnvironmentLinkUnavailableError":
156− return `Relay cannot provision the managed endpoint (${error.reason}).`;
157− case "RelayAgentActivityPublishProofExpiredError":
158− return "Relay rejected an expired agent activity publish proof.";
159− case "RelayAgentActivityPublishProofInvalidError":
160− return `Relay rejected the agent activity publish proof (${error.reason}).`;
161− case "RelayInternalError":
162− return `Relay encountered an internal error (${error.reason}).`;
163− }
164−}
165−
166−function decodedRelayClientError(message: string) {
167− return (cause: ManagedRelay.ManagedRelayClientError) => {
168− const relayError =
169− cause._tag === "ManagedRelayRequestFailedError" ? cause.relayError : undefined;
170− const traceId = cause._tag === "ManagedRelayRequestFailedError" ? cause.traceId : undefined;
171− const detail = relayError ? relayProtectedErrorMessage(relayError) : null;
172− return new CloudEnvironmentLinkError({
173− message: detail ? `${message}: ${detail}` : message,
174− cause,
175− ...(traceId ? { traceId } : {}),
176− });
177− };
178−}
179−
180−function findEnvironmentCloudApiError(cause: unknown): { readonly message: string } | null {
181− if (isEnvironmentCloudApiError(cause)) {
182− return cause;
183− }
184− if (typeof cause !== "object" || cause === null) {
185− return null;
186− }
187− return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null;
188−}
189−
190−const environmentApiError = (message: string) => (cause: unknown) => {
191− const environmentError = findEnvironmentCloudApiError(cause);
192− return new CloudEnvironmentLinkError({
193− message: environmentError
194− ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}`
195− : message,
196− cause,
354+function endpointOrigin(input: { readonly environmentId: string; readonly httpBaseUrl: string }) {
355+ return Effect.try({
356+ try: () => {
357+ const url = new URL(input.httpBaseUrl);
358+ return {
359+ localHttpHost: "127.0.0.1",
360+ localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
361+ };
362+ },
363+ catch: (cause) =>
364+ new CloudEnvironmentLinkOperationError({
365+ action: "derive the environment endpoint origin",
366+ environmentId: input.environmentId,
367+ ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
368+ cause,
369+ }),
197370 });
198−};
371+}
199372
200−function endpointOrigin(httpBaseUrl: string) {
201− const url = new URL(httpBaseUrl);
202− return {
203− localHttpHost: "127.0.0.1",
204− localHttpPort: Number(url.port || (url.protocol === "https:" ? 443 : 80)),
205− };
373+function makeCloudEnvironmentHttpApiClient(input: {
374+ readonly environmentId: string;
375+ readonly httpBaseUrl: string;
376+}) {
377+ return Effect.try({
378+ try: () => makeEnvironmentHttpApiClient(input.httpBaseUrl),
379+ catch: (cause) =>
380+ new CloudEnvironmentLinkOperationError({
381+ action: "initialize the environment HTTP client",
382+ environmentId: input.environmentId,
383+ ...httpBaseUrlDiagnosticFields(input.httpBaseUrl),
384+ cause,
385+ }),
386+ }).pipe(Effect.flatten);
206387 }
207388
208389 const MANAGED_ENDPOINT_PROVIDER_KIND =
@@ -214,13 +395,19 @@ function ensureLinkedEnvironmentMatches(input: { · 5 unmodified lines
214395 readonly link: RelayEnvironmentLinkResponse;
215396 }): Effect.Effect<void, CloudEnvironmentLinkError> {
216397 if (input.link.environmentId !== input.expectedEnvironmentId) {
217− return new CloudEnvironmentLinkError({
218− message: "Relay returned credentials for a different environment.",
398+ return new CloudEnvironmentLinkResponseMismatchError({
399+ environmentId: input.expectedEnvironmentId,
400+ field: "environment id",
401+ expected: input.expectedEnvironmentId,
402+ actual: input.link.environmentId,
219403 });
220404 }
221405 if (input.link.endpoint.providerKind !== input.expectedProviderKind) {
222− return new CloudEnvironmentLinkError({
223− message: "Relay returned credentials for a different endpoint provider.",
406+ return new CloudEnvironmentLinkResponseMismatchError({
407+ environmentId: input.expectedEnvironmentId,
408+ field: "endpoint provider",
409+ expected: input.expectedProviderKind,
410+ actual: input.link.endpoint.providerKind,
224411 });
225412 }
226413 return Effect.void;
@@ -275,9 +462,7 @@ export function listManagedCloudEnvironments(input: { · 48 unmodified lines
275462 return Effect.gen(function* () {
276463 const configuredRelayUrl = relayUrl();
277464 if (!configuredRelayUrl) {
278− return yield* new CloudEnvironmentLinkError({
279− message: "T3CODE_RELAY_URL is not configured.",
280− });
465+ return yield* new CloudRelayUrlNotConfiguredError({});
281466 }
282467 const relayClient = yield* ManagedRelay.ManagedRelayClient;
283468 return yield* relayClient
@@ -285,12 +470,12 @@ export function listManagedCloudEnvironments(input: { · 1 unmodified line
285470 clerkToken: input.clerkToken,
286471 })
287472 .pipe(
288− Effect.mapError(
289− (cause) =>
290− new CloudEnvironmentLinkError({
291− message: "Could not list relay-managed environments.",
292− cause,
293− }),
473+ Effect.mapError((cause) =>
474+ CloudEnvironmentLinkOperationError.fromManagedRelay({
475+ action: "list relay-managed environments",
476+ relayUrl: configuredRelayUrl,
477+ cause,
478+ }),
294479 ),
295480 );
296481 });
@@ -304,19 +489,18 @@ export function listCloudDevices(input: { · 7 unmodified lines
304489 ManagedRelay.ManagedRelayClient
305490 > {
306491 return Effect.gen(function* () {
307− if (!relayUrl()) {
308− return yield* new CloudEnvironmentLinkError({
309− message: "T3CODE_RELAY_URL is not configured.",
310− });
492+ const configuredRelayUrl = relayUrl();
493+ if (!configuredRelayUrl) {
494+ return yield* new CloudRelayUrlNotConfiguredError({});
311495 }
312496 const relayClient = yield* ManagedRelay.ManagedRelayClient;
313497 return yield* relayClient.listDevices({ clerkToken: input.clerkToken }).pipe(
314− Effect.mapError(
315− (cause) =>
316− new CloudEnvironmentLinkError({
317− message: "Could not list cloud devices.",
318− cause,
319− }),
498+ Effect.mapError((cause) =>
499+ CloudEnvironmentLinkOperationError.fromManagedRelay({
500+ action: "list cloud devices",
501+ relayUrl: configuredRelayUrl,
502+ cause,
503+ }),
320504 ),
321505 );
322506 });
@@ -326,10 +510,20 @@ export function readPrimaryCloudLinkState(input: { · 3 unmodified lines
326510 readonly target: CloudLinkTarget;
327511 }): Effect.Effect<CloudLinkState | null, CloudEnvironmentLinkError, HttpClient.HttpClient> {
328512 return Effect.gen(function* () {
329− const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
330− return yield* client.connect
331− .linkState({ headers: {} })
332− .pipe(Effect.mapError(environmentApiError("Could not read environment cloud link state.")));
513+ const client = yield* makeCloudEnvironmentHttpApiClient({
514+ environmentId: input.target.environmentId,
515+ httpBaseUrl: input.target.httpBaseUrl,
516+ });
517+ return yield* client.connect.linkState({ headers: {} }).pipe(
518+ Effect.mapError((cause) =>
519+ CloudEnvironmentLinkOperationError.fromEnvironmentApi({
520+ action: "read environment cloud link state",
521+ environmentId: input.target.environmentId,
522+ httpBaseUrl: input.target.httpBaseUrl,
523+ cause,
524+ }),
525+ ),
526+ );
333527 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
334528 }
335529
@@ -338,14 +532,24 @@ export function updatePrimaryCloudPreferences(input: { · 2 unmodified lines
338532 readonly publishAgentActivity: boolean;
339533 }): Effect.Effect<CloudLinkState, CloudEnvironmentLinkError, HttpClient.HttpClient> {
340534 return Effect.gen(function* () {
341− const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
535+ const client = yield* makeCloudEnvironmentHttpApiClient({
536+ environmentId: input.target.environmentId,
537+ httpBaseUrl: input.target.httpBaseUrl,
538+ });
342539 return yield* client.connect
343540 .preferences({
344541 headers: {},
345542 payload: input,
346543 })
347544 .pipe(
348− Effect.mapError(environmentApiError("Could not update environment cloud preferences.")),
545+ Effect.mapError((cause) =>
546+ CloudEnvironmentLinkOperationError.fromEnvironmentApi({
547+ action: "update environment cloud preferences",
548+ environmentId: input.target.environmentId,
549+ httpBaseUrl: input.target.httpBaseUrl,
550+ cause,
551+ }),
552+ ),
349553 );
350554 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
351555 }
@@ -359,10 +563,20 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { · 7 unmodified lines
359563 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
360564 > {
361565 return Effect.gen(function* () {
362− const client = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
363− yield* client.connect
364− .unlink({ headers: {} })
365− .pipe(Effect.mapError(environmentApiError("Could not unlink the environment from cloud.")));
566+ const client = yield* makeCloudEnvironmentHttpApiClient({
567+ environmentId: input.target.environmentId,
568+ httpBaseUrl: input.target.httpBaseUrl,
569+ });
570+ yield* client.connect.unlink({ headers: {} }).pipe(
571+ Effect.mapError((cause) =>
572+ CloudEnvironmentLinkOperationError.fromEnvironmentApi({
573+ action: "unlink the environment from cloud",
574+ environmentId: input.target.environmentId,
575+ httpBaseUrl: input.target.httpBaseUrl,
576+ cause,
577+ }),
578+ ),
579+ );
366580
367581 const configuredRelayUrl = relayUrl();
368582 if (configuredRelayUrl && input.clerkToken) {
@@ -373,11 +587,27 @@ export function unlinkPrimaryEnvironmentFromCloud(input: { · 4 unmodified lines
373587 environmentId: EnvironmentId.make(input.target.environmentId),
374588 })
375589 .pipe(
376− Effect.catch((cause) =>
377− Effect.logWarning("Could not revoke cloud environment link after local unlink.", {
590+ Effect.catch((cause) => {
591+ const error = CloudEnvironmentLinkOperationError.fromManagedRelay({
592+ action: "revoke the cloud environment link",
593+ environmentId: input.target.environmentId,
594+ relayUrl: configuredRelayUrl,
378595 cause,
379− }),
380− ),
596+ });
597+ return Effect.logWarning(error.message, {
598+ environmentId: input.target.environmentId,
599+ ...(error.relayUrlInputLength === undefined
600+ ? {}
601+ : { relayUrlInputLength: error.relayUrlInputLength }),
602+ ...(error.relayUrlProtocol === undefined
603+ ? {}
604+ : { relayUrlProtocol: error.relayUrlProtocol }),
605+ ...(error.relayUrlHostname === undefined
606+ ? {}
607+ : { relayUrlHostname: error.relayUrlHostname }),
608+ causeTag: cause._tag,
609+ });
610+ }),
381611 );
382612 }
383613 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
@@ -394,12 +624,19 @@ export function linkPrimaryEnvironmentToCloud(input: { · 10 unmodified lines
394624 return Effect.gen(function* () {
395625 const configuredRelayUrl = relayUrl();
396626 if (!configuredRelayUrl) {
397− return yield* new CloudEnvironmentLinkError({
398− message: "T3CODE_RELAY_URL is not configured.",
627+ return yield* new CloudRelayUrlNotConfiguredError({
628+ environmentId: input.target.environmentId,
399629 });
400630 }
631+ const origin = yield* endpointOrigin({
632+ environmentId: input.target.environmentId,
633+ httpBaseUrl: input.target.httpBaseUrl,
634+ });
635+ const environmentClient = yield* makeCloudEnvironmentHttpApiClient({
636+ environmentId: input.target.environmentId,
637+ httpBaseUrl: input.target.httpBaseUrl,
638+ });
401639 const relayClient = yield* ManagedRelay.ManagedRelayClient;
402− const environmentClient = yield* makeEnvironmentHttpApiClient(input.target.httpBaseUrl);
403640 yield* ensureRelayClientAvailable(EnvironmentId.make(input.target.environmentId));
404641
405642 const challenge = yield* relayClient
@@ -412,10 +649,13 @@ export function linkPrimaryEnvironmentToCloud(input: { · 6 unmodified lines
412649 },
413650 })
414651 .pipe(
415− Effect.mapError(
416− decodedRelayClientError(
417− `${configuredRelayUrl}/v1/client/environment-link-challenges failed`,
418− ),
652+ Effect.mapError((cause) =>
653+ CloudEnvironmentLinkOperationError.fromManagedRelay({
654+ action: "create an environment link challenge",
655+ environmentId: input.target.environmentId,
656+ relayUrl: configuredRelayUrl,
657+ cause,
658+ }),
419659 ),
420660 );
421661 const proof = yield* environmentClient.connect
@@ -429,10 +669,19 @@ export function linkPrimaryEnvironmentToCloud(input: { · 7 unmodified lines
429669 wsBaseUrl: input.target.wsBaseUrl,
430670 providerKind: MANAGED_ENDPOINT_PROVIDER_KIND,
431671 },
432− origin: endpointOrigin(input.target.httpBaseUrl),
672+ origin,
433673 },
434674 })
435− .pipe(Effect.mapError(environmentApiError("Could not obtain environment link proof.")));
675+ .pipe(
676+ Effect.mapError((cause) =>
677+ CloudEnvironmentLinkOperationError.fromEnvironmentApi({
678+ action: "obtain an environment link proof",
679+ environmentId: input.target.environmentId,
680+ httpBaseUrl: input.target.httpBaseUrl,
681+ cause,
682+ }),
683+ ),
684+ );
436685 const link = yield* relayClient
437686 .linkEnvironment({
438687 clerkToken: input.clerkToken,
@@ -444,8 +693,13 @@ export function linkPrimaryEnvironmentToCloud(input: { · 5 unmodified lines
444693 },
445694 })
446695 .pipe(
447− Effect.mapError(
448− decodedRelayClientError(`${configuredRelayUrl}/v1/client/environment-links failed`),
696+ Effect.mapError((cause) =>
697+ CloudEnvironmentLinkOperationError.fromManagedRelay({
698+ action: "link the environment",
699+ environmentId: input.target.environmentId,
700+ relayUrl: configuredRelayUrl,
701+ cause,
702+ }),
449703 ),
450704 );
451705 yield* ensureLinkedEnvironmentMatches({
@@ -466,6 +720,15 @@ export function linkPrimaryEnvironmentToCloud(input: { · 14 unmodified lines
466720 endpointRuntime: link.endpointRuntime,
467721 },
468722 })
469− .pipe(Effect.mapError(environmentApiError("Could not configure environment relay access.")));
723+ .pipe(
724+ Effect.mapError((cause) =>
725+ CloudEnvironmentLinkOperationError.fromEnvironmentApi({
726+ action: "configure environment relay access",
727+ environmentId: input.target.environmentId,
728+ httpBaseUrl: input.target.httpBaseUrl,
729+ cause,
730+ }),
731+ ),
732+ );
470733 }).pipe(Effect.provide(primaryEnvironmentHttpLayer));
471734 }