From 7e85f7b6ec4557f45077e2b0ac1e47a82c508ce8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 19:06:43 -0700 Subject: [PATCH 01/26] Refactor shared and SSH Effect services Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 10 +- .../desktop/src/ipc/methods/sshEnvironment.ts | 4 +- .../src/ssh/DesktopSshEnvironment.test.ts | 25 +- apps/desktop/src/ssh/DesktopSshEnvironment.ts | 74 ++- apps/server/src/ws.ts | 50 +- packages/shared/src/Net.test.ts | 4 +- packages/shared/src/Net.ts | 75 +-- packages/shared/src/relayClient.test.ts | 43 +- packages/shared/src/relayClient.ts | 433 +++++++++++---- packages/ssh/src/auth.ts | 37 +- packages/ssh/src/command.test.ts | 40 +- packages/ssh/src/command.ts | 73 ++- packages/ssh/src/config.test.ts | 10 + packages/ssh/src/config.ts | 57 +- packages/ssh/src/errors.ts | 496 ++++++++++++++++-- packages/ssh/src/tunnel.test.ts | 146 +++--- packages/ssh/src/tunnel.ts | 248 +++++---- 17 files changed, 1289 insertions(+), 536 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 88eb7c42782..656a124e601 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -10,7 +10,9 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { DesktopSshEnvironmentRequestError, @@ -24,6 +26,8 @@ const decodeDesktopSshEnvironmentEnsureResult = Schema.decodeUnknownEffect( DesktopSshEnvironmentEnsureResultSchema, ); +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); + function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( request, @@ -133,7 +137,7 @@ describe("SSH environment IPC", () => { assert.instanceOf(error, DesktopSshEnvironmentRequestError); assert.equal(error.operation, "fetch-environment-descriptor"); - assert.equal(error.cause instanceof SshHttpBridgeError, false); + assert.equal(isSshHttpBridgeError(error.cause), false); }).pipe(Effect.provide(layer)); }); @@ -158,7 +162,7 @@ describe("SSH environment IPC", () => { const error = failure.value; assert.instanceOf(error, DesktopSshEnvironmentRequestError); - assert.instanceOf(error.cause, SshHttpBridgeError); + assert.equal(isSshHttpBridgeError(error.cause), true); assert.equal(requestCount, 0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 69a3c32b1b5..a7afa1be90b 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -57,11 +57,13 @@ const isEnvironmentInternalError = Schema.is(EnvironmentInternalError); const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbiddenError); const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { - if (isRemoteEnvironmentAuthUndeclaredStatusError(cause) || cause instanceof SshHttpBridgeError) { + if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) { return cause.status ?? null; } + if (isSshHttpBridgeError(cause)) return null; if (isEnvironmentRequestInvalidError(cause)) { return 400; } diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1fe2b86aae7..d04be4e5e55 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -2,7 +2,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPromptError } from "@t3tools/ssh/errors"; +import { SshPasswordPromptRequestError } from "@t3tools/ssh/errors"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -35,18 +35,17 @@ describe("sshEnvironment", () => { }); it("treats password prompt timeouts as cancellable authentication prompts", () => { - assert.equal( - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation( - new SshPasswordPromptError({ - message: "SSH authentication timed out for devbox.", - cause: new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ - requestId: "prompt-1", - destination: "devbox", - }), - }), - ), - true, - ); + const cause = new DesktopSshPasswordPrompts.DesktopSshPromptTimedOutError({ + requestId: "prompt-1", + destination: "devbox", + }); + const error = new SshPasswordPromptRequestError({ + destination: "devbox", + cause, + }); + assert.strictEqual(error.cause, cause); + assert(DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error)); + assert.equal(error.cause.message, "SSH authentication timed out for devbox."); }); it.effect("wires desktop host discovery through the ssh package runtime", () => diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 495d5ea9c1a..9f7511dd3ac 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -7,13 +7,19 @@ import * as NetService from "@t3tools/shared/Net"; import * as SshAuth from "@t3tools/ssh/auth"; import { discoverSshHosts } from "@t3tools/ssh/config"; import { - SshCommandError, - SshHostDiscoveryError, - SshInvalidTargetError, - SshLaunchError, - SshPairingError, + type SshCommandError, + type SshHostDiscoveryError, + type SshInvalidTargetError, + type SshLaunchError, + type SshPairingError, + SshPasswordPromptCancelledError, SshPasswordPromptError, - SshReadinessError, + SshPasswordPromptSecureRandomnessError, + SshPasswordPromptServiceStoppedError, + SshPasswordPromptTimedOutError, + SshPasswordPromptWindowClosedError, + SshPasswordPromptWindowUnavailableError, + type SshReadinessError, } from "@t3tools/ssh/errors"; import * as SshTunnel from "@t3tools/ssh/tunnel"; import * as Context from "effect/Context"; @@ -21,11 +27,14 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; +const isSshPasswordPromptError = Schema.is(SshPasswordPromptError); + export type DesktopSshEnvironmentRuntimeServices = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem @@ -69,17 +78,20 @@ export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; } +type DesktopSshPasswordPromptCancellationError = SshPasswordPromptError & { + readonly cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptCancellation; +}; + function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } export function isDesktopSshPasswordPromptCancellation( error: unknown, -): error is SshPasswordPromptError & { - readonly cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptCancellation; -} { +): error is DesktopSshPasswordPromptCancellationError { return ( - error instanceof SshPasswordPromptError && + isSshPasswordPromptError(error) && + "cause" in error && DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) ); } @@ -91,31 +103,41 @@ function unexpectedPasswordPromptError(error: never): never { export function toSshPasswordPromptError( cause: DesktopSshPasswordPrompts.DesktopSshPasswordPromptRequestError, ): SshPasswordPromptError { - let message: string; switch (cause._tag) { case "DesktopSshPromptRequestIdGenerationError": - message = "Secure randomness is unavailable."; - break; + return new SshPasswordPromptSecureRandomnessError({ + destination: cause.destination, + cause, + }); case "DesktopSshPromptWindowUnavailableError": case "DesktopSshPromptPresentationError": - message = "T3 Code window is not available for SSH authentication."; - break; + return new SshPasswordPromptWindowUnavailableError({ + destination: cause.destination, + cause, + }); case "DesktopSshPromptTimedOutError": - message = `SSH authentication timed out for ${cause.destination}.`; - break; + return new SshPasswordPromptTimedOutError({ + destination: cause.destination, + cause, + }); case "DesktopSshPromptCancelledError": - message = `SSH authentication cancelled for ${cause.destination}.`; - break; + return new SshPasswordPromptCancelledError({ + destination: cause.destination, + cause, + }); case "DesktopSshPromptWindowClosedError": - message = "SSH authentication was cancelled because the app window closed."; - break; + return new SshPasswordPromptWindowClosedError({ + destination: cause.destination, + cause, + }); case "DesktopSshPromptServiceStoppedError": - message = "SSH password prompt service stopped."; - break; + return new SshPasswordPromptServiceStoppedError({ + destination: cause.destination, + cause, + }); default: return unexpectedPasswordPromptError(cause); } - return new SshPasswordPromptError({ message, cause }); } const makePasswordPrompt = ( @@ -130,7 +152,7 @@ export const make = Effect.gen(function* () { const manager = yield* SshTunnel.SshEnvironmentManager; const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; const runtimeContext = yield* Effect.context(); - const passwordPrompt = SshAuth.SshPasswordPrompt.of(makePasswordPrompt(prompts)); + const passwordPrompt = SshAuth.make(makePasswordPrompt(prompts)); return DesktopSshEnvironment.of({ discoverHosts: (input) => @@ -160,7 +182,7 @@ export const make = Effect.gen(function* () { export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) => Layer.effect(DesktopSshEnvironment, make).pipe( Layer.provide( - SshTunnel.SshEnvironmentManager.layer({ + SshTunnel.layer({ ...(options.resolveCliPackageSpec === undefined ? {} : { resolveCliPackageSpec: options.resolveCliPackageSpec }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b4f7f78e28d..eb99f0a8abf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, + type RelayClientInstallFailureReason, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, type FilesystemBrowseFailure, @@ -114,6 +115,36 @@ import { catchEnvironmentAuthenticationErrors } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); +function relayClientInstallFailureReason( + error: RelayClient.RelayClientInstallError, +): RelayClientInstallFailureReason { + switch (error._tag) { + case "RelayClientDownloadError": + case "RelayClientDownloadReadError": + return "download_failed"; + case "RelayClientChecksumMismatchError": + return "invalid_checksum"; + case "RelayClientInstallLockedError": + return "install_locked"; + case "RelayClientOverrideMissingError": + return "override_missing"; + case "RelayClientUnsupportedPlatformError": + return "unsupported_platform"; + case "RelayClientChecksumVerificationError": + case "RelayClientExecutableValidationError": + return "validation_failed"; + case "RelayClientDirectoryCreateError": + case "RelayClientInstallLockAcquireError": + case "RelayClientDownloadWriteError": + case "RelayClientArchiveExtractError": + case "RelayClientExecutablePermissionError": + case "RelayClientStageError": + case "RelayClientActivationError": + case "RelayClientInstallWriteError": + return "write_failed"; + } +} + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function unexpectedCompatibilityError(error: never): never { @@ -1289,16 +1320,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => status, }), ), - Effect.catchTags({ - RelayClientInstallError: (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), - }), + Effect.catchIf(RelayClient.isRelayClientInstallError, (error) => + Queue.fail( + queue, + new RelayClientInstallFailedError({ + reason: relayClientInstallFailureReason(error), + message: error.message, + }), + ), + ), Effect.andThen(Queue.end(queue)), Effect.forkScoped, ), diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 93a1649b25b..ca44f3e8300 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -31,9 +31,7 @@ const openServer = (host?: string): Effect.Effect { - settle( - Effect.fail(new NetService.NetError({ message: "Failed to open test server", cause })), - ); + settle(Effect.fail(new NetService.NetError({ host: host ?? "localhost", cause }))); }); if (host) { diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d7713a72612..9e3405a92b3 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -1,15 +1,19 @@ import * as NodeNet from "node:net"; -import * as Data from "effect/Data"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Context from "effect/Context"; import * as Predicate from "effect/Predicate"; - -export class NetError extends Data.TaggedError("NetError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +import * as Schema from "effect/Schema"; + +export class NetError extends Schema.TaggedErrorClass()("NetError", { + host: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) { + override get message(): string { + return `Failed to reserve loopback port on ${this.host}.`; + } +} const isErrnoExceptionWithCode = ( cause: unknown, @@ -28,34 +32,33 @@ const closeServer = (server: NodeNet.Server) => { } }; -export interface NetServiceShape { - /** - * Returns true when a TCP server can bind to {host, port}. - */ - readonly canListenOnHost: (port: number, host: string) => Effect.Effect; - - /** - * Checks loopback availability on both IPv4 and IPv6 localhost addresses. - */ - readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; - - /** - * Reserve an ephemeral loopback port and release it immediately. - */ - readonly reserveLoopbackPort: (host?: string) => Effect.Effect; - - /** - * Resolve an available listening port, preferring the provided port first. - */ - readonly findAvailablePort: (preferred: number) => Effect.Effect; -} - /** * NetService - Service tag for startup networking helpers. */ -export class NetService extends Context.Service()( - "@t3tools/shared/Net/NetService", -) {} +export class NetService extends Context.Service< + NetService, + { + /** + * Returns true when a TCP server can bind to {host, port}. + */ + readonly canListenOnHost: (port: number, host: string) => Effect.Effect; + + /** + * Checks loopback availability on both IPv4 and IPv6 localhost addresses. + */ + readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; + + /** + * Reserve an ephemeral loopback port and release it immediately. + */ + readonly reserveLoopbackPort: (host?: string) => Effect.Effect; + + /** + * Resolve an available listening port, preferring the provided port first. + */ + readonly findAvailablePort: (preferred: number) => Effect.Effect; + } +>()("@t3tools/shared/Net/NetService") {} export const make = () => { /** @@ -160,7 +163,7 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); + settle(Effect.fail(new NetError({ host, cause }))); }); probe.listen(0, host, () => { @@ -171,7 +174,7 @@ export const make = () => { settle(Effect.succeed(port)); return; } - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); + settle(Effect.fail(new NetError({ host }))); }); }); @@ -180,7 +183,7 @@ export const make = () => { }); }); - return { + return NetService.of({ canListenOnHost, isPortAvailableOnLoopback, reserveLoopbackPort, @@ -191,7 +194,7 @@ export const make = () => { } return yield* reserveLoopbackPort(); }), - } satisfies NetServiceShape; + }); }; export const layer = Layer.sync(NetService, make); diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index df39ef0b161..fefe7f31d31 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -8,15 +8,14 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; -import { - RelayClientInstallError, - CLOUDFLARED_VERSION, - makeCloudflaredRelayClient, -} from "./relayClient.ts"; +import * as RelayClient from "./relayClient.ts"; const hostRuntimeLayer = (env: Record = {}) => Layer.mergeAll( @@ -72,7 +71,7 @@ describe("RelayClient", () => { const overridePath = `${baseDir}/override-cloudflared`; yield* fileSystem.writeFileString(overridePath, "override"); yield* fileSystem.chmod(overridePath, 0o755); - const manager = yield* makeCloudflaredRelayClient({ + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, }); @@ -89,7 +88,7 @@ describe("RelayClient", () => { status: "available", executablePath: overridePath, source: "override", - version: CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }); }).pipe( Effect.scoped, @@ -111,7 +110,7 @@ describe("RelayClient", () => { prefix: "t3-cloudflared-test-", }); const bytes = new TextEncoder().encode("test-cloudflared-binary"); - const manager = yield* makeCloudflaredRelayClient({ + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, releaseAsset: { url: "https://example.test/cloudflared", @@ -128,12 +127,12 @@ describe("RelayClient", () => { } }), ); - const managedPath = `${baseDir}/tools/cloudflared/${CLOUDFLARED_VERSION}/linux-x64/cloudflared`; + const managedPath = `${baseDir}/tools/cloudflared/${RelayClient.CLOUDFLARED_VERSION}/linux-x64/cloudflared`; expect(installed).toEqual({ status: "available", executablePath: managedPath, source: "managed", - version: CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }); expect(new TextDecoder().decode(yield* fileSystem.readFile(managedPath))).toBe( "test-cloudflared-binary", @@ -167,7 +166,7 @@ describe("RelayClient", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); - const manager = yield* makeCloudflaredRelayClient({ + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, releaseAsset: { url: "https://example.test/cloudflared", @@ -177,8 +176,14 @@ describe("RelayClient", () => { }); const error = yield* manager.install.pipe(Effect.flip); - expect(error).toBeInstanceOf(RelayClientInstallError); - expect(error.reason).toBe("invalid_checksum"); + expect(error).toBeInstanceOf(RelayClient.RelayClientChecksumMismatchError); + expect(error).toMatchObject({ + expectedChecksum: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))), + actualChecksum: Encoding.encodeHex(sha256(new TextEncoder().encode("tampered"))), + }); + expect(error.message).toBe( + "Downloaded relay client checksum did not match the pinned release.", + ); }).pipe( Effect.scoped, Effect.provide( @@ -200,7 +205,7 @@ describe("RelayClient", () => { const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cloudflared-test-", }); - const manager = yield* makeCloudflaredRelayClient({ + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, releaseAsset: { url: "https://example.test/cloudflared", @@ -236,13 +241,13 @@ describe("RelayClient", () => { }); const binDir = `${baseDir}/bin`; const executablePath = `${binDir}/cloudflared`; - const manager = yield* makeCloudflaredRelayClient({ + const manager = yield* RelayClient.makeCloudflaredRelayClient({ baseDir, }); expect(yield* manager.resolve).toEqual({ status: "missing", - version: CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }); yield* fileSystem.makeDirectory(binDir); @@ -254,7 +259,7 @@ describe("RelayClient", () => { status: "available", executablePath, source: "path", - version: CLOUDFLARED_VERSION, + version: RelayClient.CLOUDFLARED_VERSION, }); }).pipe( Effect.scoped, diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 0a56e45191c..ad806457996 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -6,7 +6,6 @@ import type { import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; @@ -14,9 +13,14 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; @@ -44,23 +48,233 @@ export type RelayClientStatus = export type AvailableRelayClient = Extract; -export class RelayClientInstallError extends Data.TaggedError("RelayClientInstallError")<{ - readonly reason: - | "download_failed" - | "invalid_checksum" - | "install_locked" - | "override_missing" - | "unsupported_platform" - | "validation_failed" - | "write_failed"; - readonly message: string; - readonly cause?: unknown; -}> {} - -class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{ - readonly command: string; - readonly exitCode: number; -}> {} +export class RelayClientDownloadError extends Schema.TaggedErrorClass()( + "RelayClientDownloadError", + { + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not download the relay client."; + } +} + +export class RelayClientDownloadReadError extends Schema.TaggedErrorClass()( + "RelayClientDownloadReadError", + { + url: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not read the downloaded relay client binary."; + } +} + +export class RelayClientChecksumMismatchError extends Schema.TaggedErrorClass()( + "RelayClientChecksumMismatchError", + { + expectedChecksum: Schema.String, + actualChecksum: Schema.String, + }, +) { + override get message(): string { + return "Downloaded relay client checksum did not match the pinned release."; + } +} + +export class RelayClientInstallLockedError extends Schema.TaggedErrorClass()( + "RelayClientInstallLockedError", + { + lockPath: Schema.String, + }, +) { + override get message(): string { + return "Another relay client installation is still in progress."; + } +} + +export class RelayClientOverrideMissingError extends Schema.TaggedErrorClass()( + "RelayClientOverrideMissingError", + { + executablePath: Schema.String, + }, +) { + override get message(): string { + return `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`; + } +} + +export class RelayClientUnsupportedPlatformError extends Schema.TaggedErrorClass()( + "RelayClientUnsupportedPlatformError", + { + platform: Schema.String, + arch: Schema.String, + }, +) { + override get message(): string { + return `T3 Code does not provide a managed relay client binary for ${this.platform}-${this.arch}.`; + } +} + +export class RelayClientChecksumVerificationError extends Schema.TaggedErrorClass()( + "RelayClientChecksumVerificationError", + { + url: Schema.String, + expectedChecksum: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not verify the downloaded relay client checksum."; + } +} + +export class RelayClientExecutableValidationError extends Schema.TaggedErrorClass()( + "RelayClientExecutableValidationError", + { + executablePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "The downloaded relay client binary did not run."; + } +} + +export class RelayClientDirectoryCreateError extends Schema.TaggedErrorClass()( + "RelayClientDirectoryCreateError", + { + directoryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not create the relay client tool directory."; + } +} + +export class RelayClientInstallLockAcquireError extends Schema.TaggedErrorClass()( + "RelayClientInstallLockAcquireError", + { + lockPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not acquire the relay client installation lock."; + } +} + +export class RelayClientDownloadWriteError extends Schema.TaggedErrorClass()( + "RelayClientDownloadWriteError", + { + archivePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not write the relay client download."; + } +} + +export class RelayClientArchiveExtractError extends Schema.TaggedErrorClass()( + "RelayClientArchiveExtractError", + { + archivePath: Schema.String, + destinationDirectory: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not extract the relay client."; + } +} + +export class RelayClientExecutablePermissionError extends Schema.TaggedErrorClass()( + "RelayClientExecutablePermissionError", + { + executablePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not make the relay client executable."; + } +} + +export class RelayClientStageError extends Schema.TaggedErrorClass()( + "RelayClientStageError", + { + sourcePath: Schema.String, + destinationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not stage the relay client."; + } +} + +export class RelayClientActivationError extends Schema.TaggedErrorClass()( + "RelayClientActivationError", + { + sourcePath: Schema.String, + destinationPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not activate the relay client."; + } +} + +export class RelayClientInstallWriteError extends Schema.TaggedErrorClass()( + "RelayClientInstallWriteError", + { + managedPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not install the relay client."; + } +} + +export const RelayClientInstallError = Schema.Union([ + RelayClientDownloadError, + RelayClientDownloadReadError, + RelayClientChecksumMismatchError, + RelayClientInstallLockedError, + RelayClientOverrideMissingError, + RelayClientUnsupportedPlatformError, + RelayClientChecksumVerificationError, + RelayClientExecutableValidationError, + RelayClientDirectoryCreateError, + RelayClientInstallLockAcquireError, + RelayClientDownloadWriteError, + RelayClientArchiveExtractError, + RelayClientExecutablePermissionError, + RelayClientStageError, + RelayClientActivationError, + RelayClientInstallWriteError, +]); +export type RelayClientInstallError = typeof RelayClientInstallError.Type; + +class CloudflaredCommandError extends Schema.TaggedErrorClass()( + "CloudflaredCommandError", + { + command: Schema.String, + exitCode: Schema.Number, + }, +) { + override get message(): string { + return `${this.command} exited with code ${this.exitCode}.`; + } +} + +export const isRelayClientInstallError = Schema.is(RelayClientInstallError); export interface CloudflaredReleaseAsset { readonly url: string; @@ -123,17 +337,16 @@ export interface CloudflaredRelayClientOptions { readonly releaseAsset?: CloudflaredReleaseAsset; } -export interface RelayClientShape { - readonly resolve: Effect.Effect; - readonly install: Effect.Effect; - readonly installWithProgress: ( - report: (event: RelayClientInstallProgressEvent) => Effect.Effect, - ) => Effect.Effect; -} - -export class RelayClient extends Context.Service()( - "@t3tools/shared/relayClient", -) {} +export class RelayClient extends Context.Service< + RelayClient, + { + readonly resolve: Effect.Effect; + readonly install: Effect.Effect; + readonly installWithProgress: ( + report: (event: RelayClientInstallProgressEvent) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/shared/relayClient") {} function executableFileName(platform: NodeJS.Platform): string { return platform === "win32" ? "cloudflared.exe" : "cloudflared"; @@ -152,27 +365,17 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { const wrapInstallFailure = ( - reason: RelayClientInstallError["reason"], - message: string, + makeError: (cause: unknown) => RelayClientInstallError, ): (( effect: Effect.Effect, ) => Effect.Effect) => (effect) => - effect.pipe( - Effect.mapError( - (cause) => - new RelayClientInstallError({ - reason, - message, - cause, - }), - ), - ); + effect.pipe(Effect.mapError(makeError)); export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( options: CloudflaredRelayClientOptions, ): Effect.fn.Return< - RelayClientShape, + RelayClient["Service"], never, | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -221,7 +424,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return null; }); - const resolve: RelayClientShape["resolve"] = Effect.gen(function* () { + const resolve: RelayClient["Service"]["resolve"] = Effect.gen(function* () { const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { return (yield* isExecutableFile(config.executableOverride.value)) @@ -286,9 +489,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.mapError( (cause) => - new RelayClientInstallError({ - reason: "download_failed", - message: "Could not download the relay client.", + new RelayClientDownloadError({ + url: asset.url, cause, }), ), @@ -297,9 +499,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function yield* response.arrayBuffer.pipe( Effect.mapError( (cause) => - new RelayClientInstallError({ - reason: "download_failed", - message: "Could not read the downloaded relay client binary.", + new RelayClientDownloadReadError({ + url: asset.url, cause, }), ), @@ -309,17 +510,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const checksum = yield* crypto.digest("SHA-256", bytes).pipe( Effect.mapError( (cause) => - new RelayClientInstallError({ - reason: "validation_failed", - message: "Could not verify the downloaded relay client checksum.", + new RelayClientChecksumVerificationError({ + url: asset.url, + expectedChecksum: asset.sha256, cause, }), ), ); - if (Encoding.encodeHex(checksum) !== asset.sha256) { - return yield* new RelayClientInstallError({ - reason: "invalid_checksum", - message: "Downloaded relay client checksum did not match the pinned release.", + const actualChecksum = Encoding.encodeHex(checksum); + if (actualChecksum !== asset.sha256) { + return yield* new RelayClientChecksumMismatchError({ + expectedChecksum: asset.sha256, + actualChecksum, }); } return bytes; @@ -346,9 +548,8 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY); } - return yield* new RelayClientInstallError({ - reason: "install_locked", - message: "Another relay client installation is still in progress.", + return yield* new RelayClientInstallLockedError({ + lockPath, }); }); @@ -360,32 +561,34 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function if (existing.status === "available") return existing; const config = yield* loadCloudflaredConfig; if (Option.isSome(config.executableOverride)) { - return yield* new RelayClientInstallError({ - reason: "override_missing", - message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`, + return yield* new RelayClientOverrideMissingError({ + executablePath: config.executableOverride.value, }); } if (!releaseAsset) { - return yield* new RelayClientInstallError({ - reason: "unsupported_platform", - message: `T3 Code does not provide a managed relay client binary for ${platform}-${arch}.`, + return yield* new RelayClientUnsupportedPlatformError({ + platform, + arch, }); } const managedDirectory = path.dirname(managedPath); const lockPath = `${managedPath}.lock`; - yield* fileSystem - .makeDirectory(managedDirectory, { recursive: true }) - .pipe( - wrapInstallFailure("write_failed", "Could not create the relay client tool directory."), - ); + yield* fileSystem.makeDirectory(managedDirectory, { recursive: true }).pipe( + wrapInstallFailure( + (cause) => + new RelayClientDirectoryCreateError({ + directoryPath: managedDirectory, + cause, + }), + ), + ); yield* report("waiting_for_lock"); yield* acquireInstallLock(lockPath).pipe( Effect.catchTag("PlatformError", (cause) => Effect.fail( - new RelayClientInstallError({ - reason: "write_failed", - message: "Could not acquire the relay client installation lock.", + new RelayClientInstallLockAcquireError({ + lockPath, cause, }), ), @@ -405,37 +608,74 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); const download = yield* downloadAsset(releaseAsset, report); yield* report("installing"); - yield* fileSystem - .writeFile(archivePath, download) - .pipe(wrapInstallFailure("write_failed", "Could not write the relay client download.")); + yield* fileSystem.writeFile(archivePath, download).pipe( + wrapInstallFailure( + (cause) => + new RelayClientDownloadWriteError({ + archivePath, + cause, + }), + ), + ); const executablePath = path.join(tempDirectory, executableFileName(platform)); if (releaseAsset.archive === "tgz") { yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( - wrapInstallFailure("write_failed", "Could not extract the relay client."), + wrapInstallFailure( + (cause) => + new RelayClientArchiveExtractError({ + archivePath, + destinationDirectory: tempDirectory, + cause, + }), + ), ); } if (platform !== "win32") { - yield* fileSystem - .chmod(executablePath, 0o755) - .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); + yield* fileSystem.chmod(executablePath, 0o755).pipe( + wrapInstallFailure( + (cause) => + new RelayClientExecutablePermissionError({ + executablePath, + cause, + }), + ), + ); } yield* report("validating"); yield* runCommand(executablePath, ["--version"]).pipe( - wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), + wrapInstallFailure( + (cause) => + new RelayClientExecutableValidationError({ + executablePath, + cause, + }), + ), ); const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); - yield* fileSystem - .rename(executablePath, stagedPath) - .pipe(wrapInstallFailure("write_failed", "Could not stage the relay client.")); - yield* fileSystem - .rename(stagedPath, managedPath) - .pipe( - wrapInstallFailure("write_failed", "Could not activate the relay client."), - Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), - ); + yield* fileSystem.rename(executablePath, stagedPath).pipe( + wrapInstallFailure( + (cause) => + new RelayClientStageError({ + sourcePath: executablePath, + destinationPath: stagedPath, + cause, + }), + ), + ); + yield* fileSystem.rename(stagedPath, managedPath).pipe( + wrapInstallFailure( + (cause) => + new RelayClientActivationError({ + sourcePath: stagedPath, + destinationPath: managedPath, + cause, + }), + ), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); return { status: "available", executablePath: managedPath, @@ -446,19 +686,18 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function Effect.scoped, Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), Effect.catch((cause) => - cause instanceof RelayClientInstallError + isRelayClientInstallError(cause) ? Effect.fail(cause) : Effect.fail( - new RelayClientInstallError({ - reason: "write_failed", - message: "Could not install the relay client.", + new RelayClientInstallWriteError({ + managedPath, cause, }), ), ), ); }); - const installWithProgress: RelayClientShape["installWithProgress"] = (report) => + const installWithProgress: RelayClient["Service"]["installWithProgress"] = (report) => installSemaphore.withPermit( installUnlocked((stage) => report({ diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index ef78b2f24fe..30f591b7471 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -8,7 +8,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import { SshPasswordPromptError } from "./errors.ts"; +import type { SshPasswordPromptError } from "./errors.ts"; export interface SshPasswordRequest { readonly destination: string; @@ -34,24 +34,25 @@ export interface SshAuthOptions { readonly interactiveAuth?: boolean; } -export interface SshPasswordPromptShape { - readonly isAvailable: boolean; - readonly request: ( - request: SshPasswordRequest, - ) => Effect.Effect; -} +export class SshPasswordPrompt extends Context.Service< + SshPasswordPrompt, + { + readonly isAvailable: boolean; + readonly request: ( + request: SshPasswordRequest, + ) => Effect.Effect; + } +>()("@t3tools/ssh/auth/SshPasswordPrompt") {} -export class SshPasswordPrompt extends Context.Service()( - "@t3tools/ssh/auth/SshPasswordPrompt", -) { - static readonly disabledLayer = Layer.succeed( - SshPasswordPrompt, - SshPasswordPrompt.of({ - isAvailable: false, - request: () => Effect.succeed(null), - }), - ); -} +export const make = (service: SshPasswordPrompt["Service"]) => SshPasswordPrompt.of(service); + +export const layer = (service: SshPasswordPrompt["Service"]) => + Layer.succeed(SshPasswordPrompt, make(service)); + +export const disabledLayer = layer({ + isAvailable: false, + request: () => Effect.succeed(null), +}); export interface SshChildEnvironmentOptions { readonly interactiveAuth?: boolean; diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e5b621f87aa..63d1e88c0f6 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -8,7 +8,7 @@ import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { baseSshArgs, @@ -17,7 +17,7 @@ import { resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; -import { SshCommandError } from "./errors.ts"; +import { SshCommandExitError, SshCommandSpawnError, SshTunnelExitError } from "./errors.ts"; const encoder = new TextEncoder(); @@ -167,11 +167,43 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, SshCommandError); + assert.instanceOf(result.failure, SshCommandExitError); assert.equal(result.failure.message, "Pairing token creation failed"); assert.equal(result.failure.stdout, "Pairing token creation failed\n"); assert.equal(result.failure.stderr, ""); } + + assert.equal( + new SshCommandExitError({ + command: ["ssh"], + exitCode: 1, + stderr: "", + stdout: "", + target: "devbox", + }).message, + "SSH command failed for devbox (exit 1).", + ); + assert.equal( + new SshTunnelExitError({ + command: ["ssh"], + exitCode: 1, + stderr: "", + target: "devbox", + }).message, + "SSH tunnel exited unexpectedly for devbox (exit 1).", + ); + + const spawnCause = new Error("ssh executable path included sensitive diagnostics"); + const spawnError = new SshCommandSpawnError({ + command: ["ssh"], + exitCode: null, + stderr: "", + target: "devbox", + cause: spawnCause, + }); + assert.strictEqual(spawnError.cause, spawnCause); + assert.equal(spawnError.message, "Failed to spawn SSH command for devbox."); + assert.notInclude(spawnError.message, spawnCause.message); }).pipe(Effect.provide(processLayer)); }); @@ -197,7 +229,7 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, SshCommandError); + assert.instanceOf(result.failure, SshCommandExitError); assert.equal(result.failure.message, '{"credential":"[redacted]"}'); assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n'); } diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b43089..1872e863001 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -9,10 +9,21 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { buildSshChildEnvironment, type SshAuthOptions } from "./auth.ts"; -import { SshCommandError, SshInvalidTargetError } from "./errors.ts"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as SshAuth from "./auth.ts"; +import { + SshAuthenticationHelperError, + type SshCommandError, + SshCommandExecutionError, + SshCommandExitError, + SshCommandSpawnError, + SshCommandTimeoutError, + SshHostAliasRequiredError, + type SshInvalidTargetError, + SshTargetDestinationMissingError, +} from "./errors.ts"; const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; @@ -35,7 +46,7 @@ export interface SshCommandResult { readonly stderr: string; } -export interface RunSshCommandOptions extends SshAuthOptions { +export interface RunSshCommandOptions extends SshAuth.SshAuthOptions { readonly preHostArgs?: ReadonlyArray; readonly remoteCommandArgs?: ReadonlyArray; readonly stdin?: string; @@ -93,9 +104,10 @@ export const buildSshHostSpecEffect = ( ): Effect.Effect => Effect.try({ try: () => buildSshHostSpec(target), - catch: (cause) => - new SshInvalidTargetError({ - message: cause instanceof Error ? cause.message : "SSH target is invalid.", + catch: () => + new SshTargetDestinationMissingError({ + alias: target.alias, + hostname: target.hostname, }), }); @@ -143,20 +155,6 @@ function redactSshErrorOutput(output: string): string { : redacted; } -function normalizeSshErrorMessage(input: { - readonly stdout?: string; - readonly stderr: string; - readonly fallbackMessage: string; -}): string { - const cleanedStderr = input.stderr.trim(); - if (cleanedStderr.length > 0) { - return cleanedStderr; - } - - const cleanedStdout = input.stdout?.trim() ?? ""; - return cleanedStdout.length > 0 ? cleanedStdout : input.fallbackMessage; -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -180,17 +178,16 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const hostSpec = yield* buildSshHostSpecEffect(target); - const environment = yield* buildSshChildEnvironment({ + const environment = yield* SshAuth.buildSshChildEnvironment({ ...(input.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), ...(input.authSecret === undefined ? {} : { authSecret: input.authSecret }), }).pipe( Effect.mapError( (cause) => - new SshCommandError({ + new SshAuthenticationHelperError({ command: ["ssh"], exitCode: null, stderr: "", - message: "Failed to prepare SSH authentication helpers.", cause, }), ), @@ -226,14 +223,11 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.provideService(Scope.Scope, commandScope), Effect.mapError( (cause) => - new SshCommandError({ + new SshCommandSpawnError({ command: [sshCommand, ...args], exitCode: null, stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to spawn SSH command for ${hostSpec}.`, + target: hostSpec, cause, }), ), @@ -249,12 +243,11 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ).pipe( Effect.mapError( (cause) => - new SshCommandError({ + new SshCommandExecutionError({ command: ["ssh", ...args], exitCode: null, stderr: "", - message: - cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, + target: hostSpec, cause, }), ), @@ -269,16 +262,12 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func stdout: diagnosticStdout, stderr, }); - return yield* new SshCommandError({ + return yield* new SshCommandExitError({ command: ["ssh", ...args], exitCode, stdout: diagnosticStdout, stderr, - message: normalizeSshErrorMessage({ - stdout: diagnosticStdout, - stderr, - fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, - }), + target: hostSpec, }); } @@ -313,11 +302,11 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( preHostArgs: input.preHostArgs ?? [], hasStdin: input.stdin !== undefined, }); - return yield* new SshCommandError({ + return yield* new SshCommandTimeoutError({ command: ["ssh"], exitCode: null, stderr: "", - message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, + timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); }), }), @@ -334,7 +323,7 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi > { const trimmedAlias = alias.trim(); if (trimmedAlias.length === 0) { - return yield* new SshInvalidTargetError({ message: "SSH host alias is required." }); + return yield* new SshHostAliasRequiredError({ alias }); } yield* Effect.logDebug("ssh.target.resolve.start", { alias: trimmedAlias }); diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 0446370337a..1aeb77d73ad 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -9,6 +9,7 @@ import { parseKnownHostsHostnames, resolveSshConfigIncludePattern, } from "./config.ts"; +import { SshHostDiscoveryError } from "./errors.ts"; function makeTempHomeDir() { return Effect.gen(function* () { @@ -18,6 +19,15 @@ function makeTempHomeDir() { } describe("ssh config", () => { + it("keeps host discovery causes separate from stable operation messages", () => { + const cause = new Error("home directory included sensitive diagnostics"); + const error = new SshHostDiscoveryError({ homeDir: "/home/test", cause }); + + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "SSH host discovery failed for /home/test."); + assert.notInclude(error.message, cause.message); + }); + it.effect("discovers ssh config hosts across included files", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index bb702515a31..9471e359ee3 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -207,22 +207,30 @@ const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) { return parseKnownHostsHostnames(yield* fs.readFileString(filePath)); }); -export const discoverSshHosts = Effect.fnUntraced( - function* (input: { readonly homeDir?: string }) { - const path = yield* Path.Path; - const env = yield* Config.all({ - home: Config.string("HOME").pipe(Config.option), - userProfile: Config.string("USERPROFILE").pipe(Config.option), - }); - const homeDir = - input?.homeDir ?? - Option.getOrUndefined(env.home) ?? - Option.getOrUndefined(env.userProfile) ?? - ""; - if (homeDir.trim().length === 0) { - return []; - } +export const discoverSshHosts = Effect.fnUntraced(function* (input: { readonly homeDir?: string }) { + const path = yield* Path.Path; + const env = yield* Config.all({ + home: Config.string("HOME").pipe(Config.option), + userProfile: Config.string("USERPROFILE").pipe(Config.option), + }).pipe( + Effect.mapError( + (cause) => + new SshHostDiscoveryError({ + homeDir: input.homeDir ?? null, + cause, + }), + ), + ); + const homeDir = + input?.homeDir ?? + Option.getOrUndefined(env.home) ?? + Option.getOrUndefined(env.userProfile) ?? + ""; + if (homeDir.trim().length === 0) { + return []; + } + return yield* Effect.gen(function* () { const sshDirectory = path.join(homeDir, ".ssh"); const configAliases = yield* collectSshConfigAliasesFromFile( path.join(sshDirectory, "config"), @@ -258,12 +266,13 @@ export const discoverSshHosts = Effect.fnUntraced( return [...discovered.values()].toSorted((left, right) => left.alias.localeCompare(right.alias), ); - }, - Effect.mapError( - (cause) => - new SshHostDiscoveryError({ - message: "Failed to discover SSH hosts.", - cause, - }), - ), -); + }).pipe( + Effect.mapError( + (cause) => + new SshHostDiscoveryError({ + homeDir, + cause, + }), + ), + ); +}); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index f1ba40b560c..b13a111079e 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,47 +1,449 @@ -import * as Data from "effect/Data"; - -export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{ - readonly message: string; - readonly cause: unknown; -}> {} - -export class SshInvalidTargetError extends Data.TaggedError("SshInvalidTargetError")<{ - readonly message: string; -}> {} - -export class SshCommandError extends Data.TaggedError("SshCommandError")<{ - readonly message: string; - readonly command: readonly string[]; - readonly exitCode: number | null; - readonly stderr: string; - readonly stdout?: string; - readonly cause?: unknown; -}> {} - -export class SshLaunchError extends Data.TaggedError("SshLaunchError")<{ - readonly message: string; - readonly stdout: string; - readonly cause?: unknown; -}> {} - -export class SshPairingError extends Data.TaggedError("SshPairingError")<{ - readonly message: string; - readonly stdout: string; - readonly cause?: unknown; -}> {} - -export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{ - readonly message: string; - readonly status?: number; - readonly cause?: unknown; -}> {} - -export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +import * as Schema from "effect/Schema"; + +export class SshHostDiscoveryError extends Schema.TaggedErrorClass()( + "SshHostDiscoveryError", + { + homeDir: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `SSH host discovery failed${this.homeDir === null ? "" : ` for ${this.homeDir}`}.`; + } +} + +export class SshHostAliasRequiredError extends Schema.TaggedErrorClass()( + "SshHostAliasRequiredError", + { + alias: Schema.String, + }, +) { + override get message(): string { + return "SSH host alias is required."; + } +} + +export class SshTargetDestinationMissingError extends Schema.TaggedErrorClass()( + "SshTargetDestinationMissingError", + { + alias: Schema.String, + hostname: Schema.String, + }, +) { + override get message(): string { + return "SSH target is missing its alias/hostname."; + } +} + +export const SshInvalidTargetError = Schema.Union([ + SshHostAliasRequiredError, + SshTargetDestinationMissingError, +]); +export type SshInvalidTargetError = typeof SshInvalidTargetError.Type; + +const SshCommandErrorFields = { + command: Schema.Array(Schema.String), + exitCode: Schema.NullOr(Schema.Number), + stderr: Schema.String, + stdout: Schema.optional(Schema.String), + target: Schema.optional(Schema.String), + timeoutMs: Schema.optional(Schema.Number), +}; + +const SshCommandFailureFields = { + ...SshCommandErrorFields, + cause: Schema.Defect(), +}; + +export class SshAuthenticationHelperError extends Schema.TaggedErrorClass()( + "SshAuthenticationHelperError", + { + ...SshCommandFailureFields, + }, +) { + override get message(): string { + return `Failed to prepare SSH authentication helpers${targetSuffix(this.target)}.`; + } +} + +export class SshCommandSpawnError extends Schema.TaggedErrorClass()( + "SshCommandSpawnError", + { + ...SshCommandFailureFields, + }, +) { + override get message(): string { + return `Failed to spawn SSH command${targetSuffix(this.target)}.`; + } +} + +export class SshCommandExecutionError extends Schema.TaggedErrorClass()( + "SshCommandExecutionError", + { + ...SshCommandFailureFields, + }, +) { + override get message(): string { + return `Failed to run SSH command${targetSuffix(this.target)}.`; + } +} + +export class SshCommandExitError extends Schema.TaggedErrorClass()( + "SshCommandExitError", + { + ...SshCommandErrorFields, + }, +) { + override get message(): string { + return ( + this.stderr.trim() || + this.stdout?.trim() || + `SSH command failed${targetSuffix(this.target)}${exitCodeSuffix(this.exitCode)}.` + ); + } +} + +export class SshCommandTimeoutError extends Schema.TaggedErrorClass()( + "SshCommandTimeoutError", + { + ...SshCommandErrorFields, + }, +) { + override get message(): string { + return `SSH command timed out after ${this.timeoutMs ?? 0}ms.`; + } +} + +export class SshCommandCancelledError extends Schema.TaggedErrorClass()( + "SshCommandCancelledError", + { + ...SshCommandErrorFields, + }, +) { + override get message(): string { + return `SSH environment connection was cancelled${targetSuffix(this.target)}.`; + } +} + +export class SshTunnelSpawnError extends Schema.TaggedErrorClass()( + "SshTunnelSpawnError", + { + ...SshCommandFailureFields, + }, +) { + override get message(): string { + return `Failed to spawn SSH tunnel${targetSuffix(this.target)}.`; + } +} + +export class SshTunnelMonitorError extends Schema.TaggedErrorClass()( + "SshTunnelMonitorError", + { + ...SshCommandFailureFields, + }, +) { + override get message(): string { + return `Failed to monitor SSH tunnel${targetSuffix(this.target)}.`; + } +} + +export class SshTunnelExitError extends Schema.TaggedErrorClass()( + "SshTunnelExitError", + { + ...SshCommandErrorFields, + }, +) { + override get message(): string { + return ( + this.stderr.trim() || + this.stdout?.trim() || + `SSH tunnel exited unexpectedly${targetSuffix(this.target)}${exitCodeSuffix(this.exitCode)}.` + ); + } +} + +export const SshCommandError = Schema.Union([ + SshAuthenticationHelperError, + SshCommandSpawnError, + SshCommandExecutionError, + SshCommandExitError, + SshCommandTimeoutError, + SshCommandCancelledError, + SshTunnelSpawnError, + SshTunnelMonitorError, + SshTunnelExitError, +]); +export type SshCommandError = typeof SshCommandError.Type; + +export class SshLaunchPortMissingError extends Schema.TaggedErrorClass()( + "SshLaunchPortMissingError", + { + stdout: Schema.String, + }, +) { + override get message(): string { + return "SSH launch did not return a remote port."; + } +} + +export class SshLaunchOutputParseError extends Schema.TaggedErrorClass()( + "SshLaunchOutputParseError", + { + stdout: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "SSH launch returned unparseable output."; + } +} + +export class SshLaunchInvalidPortError extends Schema.TaggedErrorClass()( + "SshLaunchInvalidPortError", + { + stdout: Schema.String, + remotePort: Schema.optional(Schema.Number), + }, +) { + override get message(): string { + return `SSH launch returned an invalid remote port: ${String(this.remotePort)}.`; + } +} + +export const SshLaunchError = Schema.Union([ + SshLaunchPortMissingError, + SshLaunchOutputParseError, + SshLaunchInvalidPortError, +]); +export type SshLaunchError = typeof SshLaunchError.Type; + +export class SshPairingCredentialMissingError extends Schema.TaggedErrorClass()( + "SshPairingCredentialMissingError", + { + stdout: Schema.String, + }, +) { + override get message(): string { + return "SSH pairing did not return a credential."; + } +} + +export class SshPairingOutputParseError extends Schema.TaggedErrorClass()( + "SshPairingOutputParseError", + { + stdout: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "SSH pairing returned unparseable output."; + } +} + +export class SshPairingInvalidCredentialError extends Schema.TaggedErrorClass()( + "SshPairingInvalidCredentialError", + { + stdout: Schema.String, + }, +) { + override get message(): string { + return "SSH pairing command returned an invalid credential."; + } +} + +export const SshPairingError = Schema.Union([ + SshPairingCredentialMissingError, + SshPairingOutputParseError, + SshPairingInvalidCredentialError, +]); +export type SshPairingError = typeof SshPairingError.Type; + +export class SshHttpBridgeInvalidUrlError extends Schema.TaggedErrorClass()( + "SshHttpBridgeInvalidUrlError", + { + reason: Schema.Literals(["missing_url", "invalid_url"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Invalid SSH forwarded HTTP base URL."; + } +} + +export class SshHttpBridgeNonLoopbackUrlError extends Schema.TaggedErrorClass()( + "SshHttpBridgeNonLoopbackUrlError", + { + hostname: Schema.String, + }, +) { + override get message(): string { + return `SSH desktop bridge only supports loopback forwarded URLs${targetSuffix(this.hostname)}.`; + } +} + +export const SshHttpBridgeError = Schema.Union([ + SshHttpBridgeInvalidUrlError, + SshHttpBridgeNonLoopbackUrlError, +]); +export type SshHttpBridgeError = typeof SshHttpBridgeError.Type; + +export class SshReadinessProbeError extends Schema.TaggedErrorClass()( + "SshReadinessProbeError", + { + requestUrl: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Backend readiness probe failed at ${this.requestUrl}.`; + } +} + +export class SshReadinessProbeTimeoutError extends Schema.TaggedErrorClass()( + "SshReadinessProbeTimeoutError", + { + requestUrl: Schema.String, + timeoutMs: Schema.Number, + attempt: Schema.Number, + }, +) { + override get message(): string { + return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${this.requestUrl}.`; + } +} + +export class SshReadinessTimeoutError extends Schema.TaggedErrorClass()( + "SshReadinessTimeoutError", + { + requestUrl: Schema.String, + timeoutMs: Schema.Number, + attempts: Schema.Number, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${this.requestUrl}.`; + } +} + +export const SshReadinessError = Schema.Union([ + SshReadinessProbeError, + SshReadinessProbeTimeoutError, + SshReadinessTimeoutError, +]); +export type SshReadinessError = typeof SshReadinessError.Type; + +export class SshPasswordPromptUnavailableError extends Schema.TaggedErrorClass()( + "SshPasswordPromptUnavailableError", + { + destination: Schema.String, + }, +) { + override get message(): string { + return `SSH authentication failed for ${this.destination}.`; + } +} + +export class SshPasswordPromptCancelledError extends Schema.TaggedErrorClass()( + "SshPasswordPromptCancelledError", + { + destination: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `SSH authentication cancelled for ${this.destination}.`; + } +} + +export class SshPasswordPromptSecureRandomnessError extends Schema.TaggedErrorClass()( + "SshPasswordPromptSecureRandomnessError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Secure randomness is unavailable."; + } +} + +export class SshPasswordPromptWindowUnavailableError extends Schema.TaggedErrorClass()( + "SshPasswordPromptWindowUnavailableError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "T3 Code window is not available for SSH authentication."; + } +} + +export class SshPasswordPromptTimedOutError extends Schema.TaggedErrorClass()( + "SshPasswordPromptTimedOutError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `SSH authentication timed out for ${this.destination}.`; + } +} + +export class SshPasswordPromptWindowClosedError extends Schema.TaggedErrorClass()( + "SshPasswordPromptWindowClosedError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "SSH authentication was cancelled because the app window closed."; + } +} + +export class SshPasswordPromptServiceStoppedError extends Schema.TaggedErrorClass()( + "SshPasswordPromptServiceStoppedError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "SSH password prompt service stopped."; + } +} + +export class SshPasswordPromptRequestError extends Schema.TaggedErrorClass()( + "SshPasswordPromptRequestError", + { + destination: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `SSH authentication failed for ${this.destination}.`; + } +} + +export const SshPasswordPromptError = Schema.Union([ + SshPasswordPromptUnavailableError, + SshPasswordPromptCancelledError, + SshPasswordPromptSecureRandomnessError, + SshPasswordPromptWindowUnavailableError, + SshPasswordPromptTimedOutError, + SshPasswordPromptWindowClosedError, + SshPasswordPromptServiceStoppedError, + SshPasswordPromptRequestError, +]); +export type SshPasswordPromptError = typeof SshPasswordPromptError.Type; + +function targetSuffix(target: string | undefined): string { + return target ? ` for ${target}` : ""; +} + +function exitCodeSuffix(exitCode: number | null): string { + return exitCode === null ? "" : ` (exit ${exitCode})`; +} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 7e7a5a54276..274c36fa4ca 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -8,23 +8,15 @@ import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { TestClock } from "effect/testing"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { SshPasswordPrompt } from "./auth.ts"; -import { - buildRemoteLaunchScript, - buildRemotePairingScript, - buildRemoteStopScript, - buildRemoteT3RunnerScript, - describeReadinessCause, - issueRemotePairingToken, - launchOrReuseRemoteServer, - REMOTE_PICK_PORT_SCRIPT, - SshEnvironmentManager, - waitForHttpReady, -} from "./tunnel.ts"; +import * as TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as SshAuth from "./auth.ts"; +import { SshReadinessProbeTimeoutError, SshReadinessTimeoutError } from "./errors.ts"; +import * as SshTunnel from "./tunnel.ts"; const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; @@ -90,7 +82,7 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { describe("ssh tunnel scripts", () => { it("builds the remote t3 runner with npx and npm fallbacks", () => { - const script = buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); + const script = SshTunnel.buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); assert.include(script, "T3_NODE_SCRIPT_PATH=''"); assert.include(script, 'exec t3 "$@"'); @@ -116,14 +108,14 @@ describe("ssh tunnel scripts", () => { }); it("does not hard-code a remote node engine range", () => { - const script = buildRemoteT3RunnerScript(); + const script = SshTunnel.buildRemoteT3RunnerScript(); assert.include(script, "T3_NODE_ENGINE_RANGE=''"); assert.notInclude(script, TEST_NODE_ENGINE_RANGE); }); it("shell-quotes package specs in the remote t3 runner", () => { - const script = buildRemoteT3RunnerScript({ + const script = SshTunnel.buildRemoteT3RunnerScript({ packageSpec: "t3@nightly; touch /tmp/t3-owned", }); @@ -133,7 +125,7 @@ describe("ssh tunnel scripts", () => { }); it("builds the remote t3 runner with a node script override", () => { - const script = buildRemoteT3RunnerScript({ + const script = SshTunnel.buildRemoteT3RunnerScript({ nodeScriptPath: "/Users/julius/Development/Work/codething-mvp/apps/server/dist/bin.mjs", }); @@ -153,65 +145,82 @@ describe("ssh tunnel scripts", () => { } as const; assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + SshTunnel.buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), '[ -n "$REMOTE_PID" ] && [ -n "$REMOTE_PORT" ] && kill -0 "$REMOTE_PID" 2>/dev/null', ); - assert.include(buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); - assert.include(buildRemoteLaunchScript(), "ensure_remote_node_path()"); - assert.include(buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); + assert.include(SshTunnel.buildRemoteLaunchScript(), "RUNNER_CHANGED=1"); + assert.include(SshTunnel.buildRemoteLaunchScript(), "ensure_remote_node_path()"); + assert.include(SshTunnel.buildRemoteLaunchScript(), "if ! ensure_remote_node_path; then"); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + SshTunnel.buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), `T3_NODE_ENGINE_RANGE='${TEST_NODE_ENGINE_RANGE}'`, ); assert.include( - buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), + SshTunnel.buildRemoteLaunchScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }), "does not satisfy required range ", ); - assert.include(buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); - assert.include(buildRemoteLaunchScript(), "wait_ready"); - assert.include(buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); - assert.include(buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemoteLaunchScript(), "server-home"); - assert.include(buildRemoteLaunchScript(), "Remote T3 server did not become ready"); - assert.include(buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); + assert.include(SshTunnel.buildRemoteLaunchScript(), 'kill "$REMOTE_PID" 2>/dev/null || true'); + assert.include(SshTunnel.buildRemoteLaunchScript(), "wait_ready"); + assert.include(SshTunnel.buildRemoteLaunchScript(), '"$RUNNER_FILE" serve --host 127.0.0.1'); + assert.include(SshTunnel.buildRemoteLaunchScript(), '--base-dir "$DEFAULT_SERVER_HOME"'); + assert.notInclude(SshTunnel.buildRemoteLaunchScript(), "server-home"); + assert.include(SshTunnel.buildRemoteLaunchScript(), "Remote T3 server did not become ready"); + assert.include(SshTunnel.buildRemoteLaunchScript({ packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( - buildRemotePairingScript(target), + SshTunnel.buildRemotePairingScript(target), '"$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json', ); - assert.include(buildRemotePairingScript(target), 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"'); - assert.notInclude(buildRemotePairingScript(target), "server-home"); - assert.include(buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), "t3@nightly"); assert.include( - buildRemoteStopScript(target), + SshTunnel.buildRemotePairingScript(target), + 'PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME"', + ); + assert.notInclude(SshTunnel.buildRemotePairingScript(target), "server-home"); + assert.include( + SshTunnel.buildRemotePairingScript(target, { packageSpec: "t3@nightly" }), + "t3@nightly", + ); + assert.include( + SshTunnel.buildRemoteStopScript(target), 'if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ]', ); - assert.include(buildRemoteStopScript(target), 'kill "$REMOTE_PID" 2>/dev/null || true'); - assert.include(buildRemoteStopScript(target), 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"'); assert.include( - buildRemoteLaunchScript(), + SshTunnel.buildRemoteStopScript(target), + 'kill "$REMOTE_PID" 2>/dev/null || true', + ); + assert.include( + SshTunnel.buildRemoteStopScript(target), + 'rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE"', + ); + assert.include( + SshTunnel.buildRemoteLaunchScript(), 'DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"', ); - assert.include(buildRemoteLaunchScript(), "resolve_default_runtime_port()"); + assert.include(SshTunnel.buildRemoteLaunchScript(), "resolve_default_runtime_port()"); assert.include( - buildRemoteLaunchScript(), + SshTunnel.buildRemoteLaunchScript(), 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', ); assert.include( - buildRemoteLaunchScript(), + SshTunnel.buildRemoteLaunchScript(), "if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(port))", ); - assert.include(buildRemoteLaunchScript(), 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"'); - assert.include(buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); - assert.include(buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); - assert.include(buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); - assert.include(buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); + assert.include( + SshTunnel.buildRemoteLaunchScript(), + 'PID_TO_STOP="${REMOTE_PID:-$DEFAULT_RUNTIME_PID}"', + ); + assert.include(SshTunnel.buildRemoteLaunchScript(), 'REMOTE_PORT="$DEFAULT_REMOTE_PORT"'); + assert.include(SshTunnel.buildRemoteLaunchScript(), 'rm -f "$PID_FILE"'); + assert.include(SshTunnel.buildRemoteLaunchScript(), "printf 'external\\n' >\"$MANAGED_FILE\""); + assert.include(SshTunnel.buildRemoteLaunchScript(), 'if [ -z "$REMOTE_PORT" ]; then'); assert.isBelow( - buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), - buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), + SshTunnel.buildRemoteLaunchScript().indexOf('if [ "$REMOTE_MANAGED" = "managed" ]'), + SshTunnel.buildRemoteLaunchScript().indexOf("printf 'external\\n' >\"$MANAGED_FILE\""), ); assert.isBelow( - buildRemoteLaunchScript().indexOf('DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port'), - buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), + SshTunnel.buildRemoteLaunchScript().indexOf( + 'DEFAULT_RUNTIME_INFO="$(resolve_default_runtime_port', + ), + SshTunnel.buildRemoteLaunchScript().indexOf('elif [ -n "$REMOTE_PID" ]'), ); }); @@ -229,21 +238,22 @@ describe("ssh tunnel scripts", () => { const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* launchOrReuseRemoteServer(target); + const result = yield* SshTunnel.launchOrReuseRemoteServer(target); assert.equal(result.remotePort, 3774); }).pipe(Effect.provide(processLayer)); }); it("allows the remote port picker to run without a state file path", () => { - assert.include(REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); + assert.include(SshTunnel.REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); it.effect("bounds each HTTP readiness probe so retries cannot hang on one request", () => Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.result( - waitForHttpReady({ + SshTunnel.waitForHttpReady({ baseUrl: "http://127.0.0.1:41773/", + path: "/ready", timeoutMs: 1_000, intervalMs: 100, probeTimeoutMs: 250, @@ -257,7 +267,15 @@ describe("ssh tunnel scripts", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.include(result.failure.message, "Timed out waiting 1000ms"); + assert.instanceOf(result.failure, SshReadinessTimeoutError); + const timeoutError = result.failure as SshReadinessTimeoutError; + assert.equal(timeoutError.requestUrl, "http://127.0.0.1:41773/ready"); + assert.include(timeoutError.message, "Timed out waiting 1000ms"); + assert.isAbove(timeoutError.attempts, 0); + assert.instanceOf(timeoutError.cause, SshReadinessProbeTimeoutError); + const probeTimeout = timeoutError.cause as SshReadinessProbeTimeoutError; + assert.equal(probeTimeout.attempt, timeoutError.attempts); + assert.isFalse("cause" in probeTimeout); } }).pipe( Effect.provide( @@ -268,7 +286,7 @@ describe("ssh tunnel scripts", () => { it("preserves primitive readiness reason values in diagnostic output", () => { assert.deepEqual( - describeReadinessCause({ + SshTunnel.describeReadinessCause({ _tag: "HttpClientError", message: "Backend readiness probe failed.", reason: "authentication failed", @@ -305,7 +323,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* SshTunnel.issueRemotePairingToken(target); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -333,7 +351,7 @@ describe("ssh tunnel scripts", () => { const spawnerLayer = Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner); const processLayer = Layer.merge(NodeServices.layer, spawnerLayer); return Effect.gen(function* () { - const result = yield* issueRemotePairingToken(target); + const result = yield* SshTunnel.issueRemotePairingToken(target); assert.equal(result.credential, "LCL4R2TPHDKQ"); }).pipe(Effect.provide(processLayer)); }); @@ -366,8 +384,8 @@ describe("ssh tunnel scripts", () => { Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), Layer.succeed(HttpClient.HttpClient, testHttpClient), Layer.succeed(NetService.NetService, testNetService), - SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), + SshAuth.disabledLayer, + SshTunnel.layer(), ); const target = { alias: "devbox", @@ -377,7 +395,7 @@ describe("ssh tunnel scripts", () => { } as const; return Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; + const manager = yield* SshTunnel.SshEnvironmentManager; const first = yield* manager.ensureEnvironment(target); assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index e8c2b924759..e738f62201e 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -19,15 +19,12 @@ import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { HttpClient, HttpClientRequest } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; -import { - buildSshChildEnvironment, - type SshAuthOptions, - SshPasswordPrompt, - isSshAuthFailure, -} from "./auth.ts"; +import * as SshAuth from "./auth.ts"; import { baseSshArgs, buildSshHostSpecEffect, @@ -40,15 +37,35 @@ import { targetConnectionKey, } from "./command.ts"; import { + SshAuthenticationHelperError, + SshCommandCancelledError, SshCommandError, + SshHttpBridgeInvalidUrlError, + SshHttpBridgeNonLoopbackUrlError, SshHttpBridgeError, SshInvalidTargetError, + SshLaunchInvalidPortError, + SshLaunchOutputParseError, + SshLaunchPortMissingError, SshLaunchError, + SshPairingCredentialMissingError, + SshPairingInvalidCredentialError, + SshPairingOutputParseError, SshPairingError, + SshPasswordPromptCancelledError, + SshPasswordPromptUnavailableError, SshPasswordPromptError, + SshReadinessProbeError, + SshReadinessProbeTimeoutError, + SshReadinessTimeoutError, SshReadinessError, + SshTunnelExitError, + SshTunnelMonitorError, + SshTunnelSpawnError, } from "./errors.ts"; +const isSshReadinessError = Schema.is(SshReadinessError); + export const DEFAULT_REMOTE_PORT = 3773; const REMOTE_PORT_SCAN_WINDOW = 200; const SSH_READY_TIMEOUT_MS = 20_000; @@ -86,7 +103,7 @@ type SshEnvironmentEffectContext = | Path.Path | HttpClient.HttpClient | NetService.NetService - | SshPasswordPrompt; + | SshAuth.SshPasswordPrompt; type SshEnvironmentEffectError = | SshCommandError @@ -98,11 +115,11 @@ type SshEnvironmentEffectError = | NetService.NetError; function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { - return new SshCommandError({ + return new SshCommandCancelledError({ command: ["ssh"], exitCode: null, stderr: "", - message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`, + target: target.alias || target.hostname, }); } @@ -129,7 +146,7 @@ interface SshAuthOperationInput { readonly key: string; readonly target: DesktopSshEnvironmentTarget; readonly operation: ( - authOptions: SshAuthOptions, + authOptions: SshAuth.SshAuthOptions, ) => Effect.Effect; } @@ -138,19 +155,25 @@ interface SshAuthAttemptInput extends SshAuthOperationInput { readonly authSecret: string | null; } -export interface SshEnvironmentManagerShape { - readonly ensureEnvironment: ( - target: DesktopSshEnvironmentTarget, - options?: { readonly issuePairingToken?: boolean }, - ) => Effect.Effect< - DesktopSshEnvironmentBootstrap, - SshEnvironmentEffectError, - SshEnvironmentEffectContext - >; - readonly disconnectEnvironment: ( - target: DesktopSshEnvironmentTarget, - ) => Effect.Effect; -} +/** + * @effect-expect-leaking ChildProcessSpawner | FileSystem | HttpClient | NetService | Path | SshAuth.SshPasswordPrompt + */ +export class SshEnvironmentManager extends Context.Service< + SshEnvironmentManager, + { + readonly ensureEnvironment: ( + target: DesktopSshEnvironmentTarget, + options?: { readonly issuePairingToken?: boolean }, + ) => Effect.Effect< + DesktopSshEnvironmentBootstrap, + SshEnvironmentEffectError, + SshEnvironmentEffectContext + >; + readonly disconnectEnvironment: ( + target: DesktopSshEnvironmentTarget, + ) => Effect.Effect; + } +>()("@t3tools/ssh/tunnel/SshEnvironmentManager") {} const RemoteLaunchResult = Schema.Struct({ remotePort: Schema.Number, @@ -208,11 +231,6 @@ function buildRemoteNodeEngineCheckScript(): string { (${remoteNodeEngineCheckMain.toString()})();`; } -export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { - const cleaned = stderr.trim(); - return cleaned.length > 0 ? cleaned : fallbackMessage; -} - function stripTrailingNewlines(value: string): string { return value.replace(/\n+$/u, ""); } @@ -233,11 +251,12 @@ function applyScriptPlaceholders( } export function describeReadinessCause(cause: unknown): unknown { - if (cause instanceof SshReadinessError) { + if (isSshReadinessError(cause)) { + const nestedCause = "cause" in cause ? cause.cause : undefined; return { _tag: cause._tag, message: cause.message, - ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), + ...(nestedCause === undefined ? {} : { cause: describeReadinessCause(nestedCause) }), }; } if (cause instanceof Error) { @@ -714,7 +733,7 @@ function buildRemoteLogTailScript(target: DesktopSshEnvironmentTarget): string { export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemoteServer")( function* ( target: DesktopSshEnvironmentTarget, - input?: SshAuthOptions, + input?: SshAuth.SshAuthOptions, runner?: RemoteT3RunnerOptions, ): Effect.fn.Return< { readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null }, @@ -734,25 +753,23 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); if (!getLastNonEmptyOutputLine(result.stdout)) { - return yield* new SshLaunchError({ - message: "SSH launch did not return a remote port.", + return yield* new SshLaunchPortMissingError({ stdout: result.stdout, }); } const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe( Effect.mapError( (cause) => - new SshLaunchError({ - message: "SSH launch returned unparseable output.", + new SshLaunchOutputParseError({ stdout: result.stdout, cause, }), ), ); if (!Number.isInteger(parsed.remotePort)) { - return yield* new SshLaunchError({ - message: `SSH launch returned an invalid remote port: ${String(parsed.remotePort)}.`, + return yield* new SshLaunchInvalidPortError({ stdout: result.stdout, + remotePort: parsed.remotePort, }); } yield* Effect.logInfo("ssh.remoteServer.launch.ready", { @@ -770,7 +787,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingToken")(function* ( target: DesktopSshEnvironmentTarget, - input?: SshAuthOptions, + input?: SshAuth.SshAuthOptions, runner?: RemoteT3RunnerOptions, ): Effect.fn.Return< { @@ -791,24 +808,21 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT ...(input?.interactiveAuth === undefined ? {} : { interactiveAuth: input.interactiveAuth }), }); if (!getLastNonEmptyOutputLine(result.stdout)) { - return yield* new SshPairingError({ - message: "SSH pairing did not return a credential.", + return yield* new SshPairingCredentialMissingError({ stdout: result.stdout, }); } const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe( Effect.mapError( (cause) => - new SshPairingError({ - message: "SSH pairing returned unparseable output.", + new SshPairingOutputParseError({ stdout: result.stdout, cause, }), ), ); if (parsed.credential.trim().length === 0) { - return yield* new SshPairingError({ - message: "SSH pairing command returned an invalid credential.", + return yield* new SshPairingInvalidCredentialError({ stdout: result.stdout, }); } @@ -823,7 +837,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( target: DesktopSshEnvironmentTarget, - input?: SshAuthOptions, + input?: SshAuth.SshAuthOptions, ): Effect.fn.Return< void, SshCommandError | SshInvalidTargetError, @@ -848,7 +862,7 @@ export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(functio const readRemoteServerLogTail = Effect.fn("ssh/tunnel.readRemoteServerLogTail")(function* ( target: DesktopSshEnvironmentTarget, - input?: SshAuthOptions, + input?: SshAuth.SshAuthOptions, ): Effect.fn.Return< string, SshCommandError | SshInvalidTargetError, @@ -900,8 +914,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio Effect.timeoutOption(Duration.millis(probeTimeoutMs)), Effect.mapError( (cause) => - new SshReadinessError({ - message: `Backend readiness probe failed at ${requestUrl}.`, + new SshReadinessProbeError({ + requestUrl, cause, }), ), @@ -910,31 +924,23 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio onSome: Effect.succeed, onNone: () => Effect.fail( - new SshReadinessError({ - message: `Backend readiness probe exceeded ${probeTimeoutMs}ms at ${requestUrl}.`, - cause: { - kind: "probe-timeout", - attempt, - probeTimeoutMs, - }, + new SshReadinessProbeTimeoutError({ + requestUrl, + timeoutMs: probeTimeoutMs, + attempt, }), ), }); }).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause - : new SshReadinessError({ - message: `Backend readiness probe failed at ${requestUrl}.`, + : new SshReadinessProbeError({ + requestUrl, cause, }), ), - Effect.tapError((cause) => - Ref.set(lastProbeFailure, { - attempt, - cause: describeReadinessCause(cause), - }), - ), + Effect.tapError((cause) => Ref.set(lastProbeFailure, cause)), ), ), HttpClient.tap((response) => response.text.pipe(Effect.ignore)), @@ -943,10 +949,10 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio const result = yield* readinessClient.execute(HttpClientRequest.get(requestUrl)).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause - : new SshReadinessError({ - message: `Backend readiness probe failed at ${requestUrl}.`, + : new SshReadinessProbeError({ + requestUrl, cause, }), ), @@ -970,11 +976,13 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio intervalMs, probeTimeoutMs, attempts: attempt, - lastFailure, + lastFailure: describeReadinessCause(lastFailure), }); - return yield* new SshReadinessError({ - message: `Timed out waiting ${timeoutMs}ms for backend readiness at ${input.baseUrl}.`, - cause: lastFailure, + return yield* new SshReadinessTimeoutError({ + requestUrl, + timeoutMs, + attempts: attempt, + ...(lastFailure === null ? {} : { cause: lastFailure }), }); }), }); @@ -990,23 +998,19 @@ function isLoopbackHostname(hostname: string): boolean { export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")( function* (rawHttpBaseUrl: unknown): Effect.fn.Return { - return yield* Effect.try({ - try: () => { - if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { - throw new Error("Invalid SSH forwarded http base URL."); - } - const baseUrl = new URL(rawHttpBaseUrl); - if (!isLoopbackHostname(baseUrl.hostname)) { - throw new Error("SSH desktop bridge only supports loopback forwarded URLs."); - } - return baseUrl.toString(); - }, - catch: (cause) => - new SshHttpBridgeError({ - message: cause instanceof Error ? cause.message : "Invalid SSH forwarded http base URL.", - cause, - }), + if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { + return yield* new SshHttpBridgeInvalidUrlError({ reason: "missing_url" }); + } + const baseUrl = yield* Effect.try({ + try: () => new URL(rawHttpBaseUrl), + catch: (cause) => new SshHttpBridgeInvalidUrlError({ reason: "invalid_url", cause }), }); + if (!isLoopbackHostname(baseUrl.hostname)) { + return yield* new SshHttpBridgeNonLoopbackUrlError({ + hostname: baseUrl.hostname, + }); + } + return baseUrl.toString(); }, ); @@ -1022,7 +1026,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: readonly localPort: number; readonly httpBaseUrl: string; readonly wsBaseUrl: string; - readonly authOptions: SshAuthOptions; + readonly authOptions: SshAuth.SshAuthOptions; readonly remoteServerKind: "external" | "managed" | null; }): Effect.fn.Return< SshTunnelEntry, @@ -1035,7 +1039,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: | Scope.Scope > { const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget); - const childEnvironment = yield* buildSshChildEnvironment({ + const childEnvironment = yield* SshAuth.buildSshChildEnvironment({ ...(input.authOptions.authSecret === undefined ? {} : { authSecret: input.authOptions.authSecret }), @@ -1045,11 +1049,10 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }).pipe( Effect.mapError( (cause) => - new SshCommandError({ + new SshAuthenticationHelperError({ command: ["ssh"], exitCode: null, stderr: "", - message: "Failed to prepare SSH authentication helpers.", cause, }), ), @@ -1096,14 +1099,11 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: .pipe( Effect.mapError( (cause) => - new SshCommandError({ + new SshTunnelSpawnError({ command: tunnelCommand, exitCode: null, stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to spawn SSH tunnel for ${input.resolvedTarget.alias}.`, + target: input.resolvedTarget.alias, cause, }), ), @@ -1133,26 +1133,20 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ).pipe( Effect.mapError( (cause) => - new SshCommandError({ + new SshTunnelMonitorError({ command: tunnelCommand, exitCode: null, stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to monitor SSH tunnel for ${input.resolvedTarget.alias}.`, + target: input.resolvedTarget.alias, cause, }), ), Effect.flatMap(([stderr, exitCode]) => { - const error = new SshCommandError({ + const error = new SshTunnelExitError({ command: tunnelCommand, exitCode, stderr, - message: normalizeSshErrorMessage( - stderr, - `SSH tunnel exited unexpectedly for ${input.resolvedTarget.alias} (exit ${exitCode}).`, - ), + target: input.resolvedTarget.alias, }); return Effect.logWarning("ssh.tunnel.process.exited", { ...sshTargetLogFields(input.resolvedTarget), @@ -1237,9 +1231,9 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: return tunnelEntry; }); -const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* ( +export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* ( options: SshEnvironmentManagerOptions = {}, -): Effect.fn.Return { +): Effect.fn.Return { const managerScope = yield* Scope.Scope; const tunnels = new Map(); const pendingTunnelEntries = new Map< @@ -1291,16 +1285,20 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma const promptForPassword = Effect.fn("ssh/tunnel.promptForPassword")(function* ( target: DesktopSshEnvironmentTarget, attempt: number, - ): Effect.fn.Return { - const promptService = yield* SshPasswordPrompt; + ): Effect.fn.Return< + string, + SshInvalidTargetError | SshPasswordPromptError, + SshAuth.SshPasswordPrompt + > { + const promptService = yield* SshAuth.SshPasswordPrompt; const hostSpec = yield* buildSshHostSpecEffect(target); if (!promptService.isAvailable) { yield* Effect.logWarning("ssh.auth.passwordPrompt.unavailable", { ...sshTargetLogFields(target), attempt, }); - return yield* new SshPasswordPromptError({ - message: `SSH authentication failed for ${hostSpec}.`, + return yield* new SshPasswordPromptUnavailableError({ + destination: hostSpec, }); } @@ -1319,8 +1317,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(target), attempt, }); - return yield* new SshPasswordPromptError({ - message: `SSH authentication cancelled for ${hostSpec}.`, + return yield* new SshPasswordPromptCancelledError({ + destination: hostSpec, }); } yield* Effect.logInfo("ssh.auth.passwordPrompt.received", { @@ -1336,7 +1334,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma readonly error: SshEnvironmentEffectError; }, ): Effect.fn.Return { - if (!isSshAuthFailure(input.error)) { + if (!SshAuth.isSshAuthFailure(input.error)) { return yield* input.error; } @@ -1346,7 +1344,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma promptCount: input.promptCount, cause: input.error, }); - const promptService = yield* SshPasswordPrompt; + const promptService = yield* SshAuth.SshPasswordPrompt; if (!promptService.isAvailable) { return yield* input.error; } @@ -1371,7 +1369,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma const runWithSshAuthAttempt = Effect.fn("ssh/tunnel.runWithSshAuthAttempt")(function* ( input: SshAuthAttemptInput, ): Effect.fn.Return { - const promptService = yield* SshPasswordPrompt; + const promptService = yield* SshAuth.SshPasswordPrompt; const authOptions = input.authSecret === null ? { @@ -1686,13 +1684,5 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment }); }); -/** - * @effect-expect-leaking ChildProcessSpawner | FileSystem | HttpClient | NetService | Path | SshPasswordPrompt - */ -export class SshEnvironmentManager extends Context.Service< - SshEnvironmentManager, - SshEnvironmentManagerShape ->()("@t3tools/ssh/tunnel/SshEnvironmentManager") { - static readonly layer = (options: SshEnvironmentManagerOptions = {}) => - Layer.effect(SshEnvironmentManager, makeSshEnvironmentManager(options)); -} +export const layer = (options: SshEnvironmentManagerOptions = {}) => + Layer.effect(SshEnvironmentManager, make(options)); From 57b8f5654e0534909946cdd8af4dcc2ce324d1d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:39:02 -0700 Subject: [PATCH 02/26] Preserve SSH and network error messages Co-authored-by: codex --- .../src/ssh/DesktopSshEnvironment.test.ts | 9 ++-- packages/shared/src/Net.test.ts | 5 +++ packages/shared/src/Net.ts | 2 +- packages/ssh/src/command.test.ts | 27 ++---------- packages/ssh/src/config.test.ts | 2 +- packages/ssh/src/errors.ts | 36 ++++++++++----- packages/ssh/src/tunnel.test.ts | 44 ++++++++++++++++++- packages/ssh/src/tunnel.ts | 6 ++- 8 files changed, 84 insertions(+), 47 deletions(-) diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index d04be4e5e55..219b36130fd 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts @@ -2,7 +2,6 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as NetService from "@t3tools/shared/Net"; -import { SshPasswordPromptRequestError } from "@t3tools/ssh/errors"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -39,12 +38,10 @@ describe("sshEnvironment", () => { requestId: "prompt-1", destination: "devbox", }); - const error = new SshPasswordPromptRequestError({ - destination: "devbox", - cause, - }); - assert.strictEqual(error.cause, cause); + const error = DesktopSshEnvironment.toSshPasswordPromptError(cause); assert(DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error)); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "SSH authentication timed out for devbox."); assert.equal(error.cause.message, "SSH authentication timed out for devbox."); }); diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index ca44f3e8300..7c3325fce2e 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -45,6 +45,11 @@ const openServer = (host?: string): Effect.Effect { describe("Net helpers", () => { + it("preserves the loopback reservation error message", () => { + const error = new NetService.NetError({ host: "127.0.0.1" }); + assert.equal(error.message, "Failed to reserve loopback port"); + }); + it.effect("reserveLoopbackPort returns a positive loopback port", () => Effect.gen(function* () { const net = yield* NetService.NetService; diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index 9e3405a92b3..d5d4cfafac0 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -11,7 +11,7 @@ export class NetError extends Schema.TaggedErrorClass()("NetError", { cause: Schema.optional(Schema.Defect()), }) { override get message(): string { - return `Failed to reserve loopback port on ${this.host}.`; + return "Failed to reserve loopback port"; } } diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 63d1e88c0f6..7767ae7daec 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -17,7 +17,7 @@ import { resolveRemoteT3CliPackageSpec, runSshCommand, } from "./command.ts"; -import { SshCommandExitError, SshCommandSpawnError, SshTunnelExitError } from "./errors.ts"; +import { SshCommandExitError, SshCommandSpawnError } from "./errors.ts"; const encoder = new TextEncoder(); @@ -173,27 +173,7 @@ describe("ssh command", () => { assert.equal(result.failure.stderr, ""); } - assert.equal( - new SshCommandExitError({ - command: ["ssh"], - exitCode: 1, - stderr: "", - stdout: "", - target: "devbox", - }).message, - "SSH command failed for devbox (exit 1).", - ); - assert.equal( - new SshTunnelExitError({ - command: ["ssh"], - exitCode: 1, - stderr: "", - target: "devbox", - }).message, - "SSH tunnel exited unexpectedly for devbox (exit 1).", - ); - - const spawnCause = new Error("ssh executable path included sensitive diagnostics"); + const spawnCause = new Error("ssh executable was not found"); const spawnError = new SshCommandSpawnError({ command: ["ssh"], exitCode: null, @@ -202,8 +182,7 @@ describe("ssh command", () => { cause: spawnCause, }); assert.strictEqual(spawnError.cause, spawnCause); - assert.equal(spawnError.message, "Failed to spawn SSH command for devbox."); - assert.notInclude(spawnError.message, spawnCause.message); + assert.equal(spawnError.message, "ssh executable was not found"); }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts index 1aeb77d73ad..1481a022705 100644 --- a/packages/ssh/src/config.test.ts +++ b/packages/ssh/src/config.test.ts @@ -24,7 +24,7 @@ describe("ssh config", () => { const error = new SshHostDiscoveryError({ homeDir: "/home/test", cause }); assert.strictEqual(error.cause, cause); - assert.equal(error.message, "SSH host discovery failed for /home/test."); + assert.equal(error.message, "Failed to discover SSH hosts."); assert.notInclude(error.message, cause.message); }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index b13a111079e..f053d960f1b 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -8,7 +8,7 @@ export class SshHostDiscoveryError extends Schema.TaggedErrorClass()( + "SshHttpBridgeMissingUrlError", + {}, +) { + override get message(): string { + return "Invalid SSH forwarded http base URL."; + } +} + export class SshHttpBridgeInvalidUrlError extends Schema.TaggedErrorClass()( "SshHttpBridgeInvalidUrlError", { - reason: Schema.Literals(["missing_url", "invalid_url"]), - cause: Schema.optional(Schema.Defect()), + cause: Schema.Defect(), }, ) { override get message(): string { - return "Invalid SSH forwarded HTTP base URL."; + return causeMessage(this.cause, "Invalid SSH forwarded http base URL."); } } @@ -277,11 +285,12 @@ export class SshHttpBridgeNonLoopbackUrlError extends Schema.TaggedErrorClass()( "SshReadinessTimeoutError", { + baseUrl: Schema.String, requestUrl: Schema.String, timeoutMs: Schema.Number, attempts: Schema.Number, @@ -322,7 +332,7 @@ export class SshReadinessTimeoutError extends Schema.TaggedErrorClass { if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshReadinessTimeoutError); const timeoutError = result.failure as SshReadinessTimeoutError; + assert.equal(timeoutError.baseUrl, "http://127.0.0.1:41773/"); assert.equal(timeoutError.requestUrl, "http://127.0.0.1:41773/ready"); - assert.include(timeoutError.message, "Timed out waiting 1000ms"); + assert.equal( + timeoutError.message, + "Timed out waiting 1000ms for backend readiness at http://127.0.0.1:41773/.", + ); assert.isAbove(timeoutError.attempts, 0); assert.instanceOf(timeoutError.cause, SshReadinessProbeTimeoutError); const probeTimeout = timeoutError.cause as SshReadinessProbeTimeoutError; @@ -284,6 +294,36 @@ describe("ssh tunnel scripts", () => { ), ); + it.effect("preserves forwarded HTTP URL error messages", () => + Effect.gen(function* () { + const missing = yield* Effect.result(SshTunnel.resolveLoopbackSshHttpBaseUrl("")); + assert.isTrue(Result.isFailure(missing)); + if (Result.isFailure(missing)) { + assert.instanceOf(missing.failure, SshHttpBridgeMissingUrlError); + assert.equal(missing.failure.message, "Invalid SSH forwarded http base URL."); + } + + const invalid = yield* Effect.result(SshTunnel.resolveLoopbackSshHttpBaseUrl("not-a-url")); + assert.isTrue(Result.isFailure(invalid)); + if (Result.isFailure(invalid)) { + assert.instanceOf(invalid.failure, SshHttpBridgeInvalidUrlError); + assert.equal(invalid.failure.message, "Invalid URL"); + } + + const nonLoopback = yield* Effect.result( + SshTunnel.resolveLoopbackSshHttpBaseUrl("https://example.com"), + ); + assert.isTrue(Result.isFailure(nonLoopback)); + if (Result.isFailure(nonLoopback)) { + assert.instanceOf(nonLoopback.failure, SshHttpBridgeNonLoopbackUrlError); + assert.equal( + nonLoopback.failure.message, + "SSH desktop bridge only supports loopback forwarded URLs.", + ); + } + }), + ); + it("preserves primitive readiness reason values in diagnostic output", () => { assert.deepEqual( SshTunnel.describeReadinessCause({ diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index e738f62201e..32935fd1f49 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -41,6 +41,7 @@ import { SshCommandCancelledError, SshCommandError, SshHttpBridgeInvalidUrlError, + SshHttpBridgeMissingUrlError, SshHttpBridgeNonLoopbackUrlError, SshHttpBridgeError, SshInvalidTargetError, @@ -979,6 +980,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio lastFailure: describeReadinessCause(lastFailure), }); return yield* new SshReadinessTimeoutError({ + baseUrl: input.baseUrl, requestUrl, timeoutMs, attempts: attempt, @@ -999,11 +1001,11 @@ function isLoopbackHostname(hostname: string): boolean { export const resolveLoopbackSshHttpBaseUrl = Effect.fn("ssh/tunnel.resolveLoopbackSshHttpBaseUrl")( function* (rawHttpBaseUrl: unknown): Effect.fn.Return { if (typeof rawHttpBaseUrl !== "string" || rawHttpBaseUrl.trim().length === 0) { - return yield* new SshHttpBridgeInvalidUrlError({ reason: "missing_url" }); + return yield* new SshHttpBridgeMissingUrlError(); } const baseUrl = yield* Effect.try({ try: () => new URL(rawHttpBaseUrl), - catch: (cause) => new SshHttpBridgeInvalidUrlError({ reason: "invalid_url", cause }), + catch: (cause) => new SshHttpBridgeInvalidUrlError({ cause }), }); if (!isLoopbackHostname(baseUrl.hostname)) { return yield* new SshHttpBridgeNonLoopbackUrlError({ From af2366ab7b4a6421d5389ea927c419f78092319b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:44:21 -0700 Subject: [PATCH 03/26] Keep structured SSH errors stable Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 23 +++++++++++++- .../desktop/src/ipc/methods/sshEnvironment.ts | 30 +++++++++++++++++-- packages/ssh/src/command.test.ts | 3 +- packages/ssh/src/errors.ts | 14 ++++----- packages/ssh/src/tunnel.test.ts | 2 +- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 656a124e601..acadcf3f46c 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -3,7 +3,11 @@ import { DesktopSshEnvironmentEnsureResultSchema, DesktopSshPasswordPromptCancellationError, } from "@t3tools/contracts"; -import { SshHttpBridgeError, SshPasswordPromptError } from "@t3tools/ssh/errors"; +import { + SshCommandSpawnError, + SshHttpBridgeError, + SshPasswordPromptError, +} from "@t3tools/ssh/errors"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -18,6 +22,7 @@ import { DesktopSshEnvironmentRequestError, ensureSshEnvironment, fetchSshEnvironmentDescriptor, + toDesktopSshOperationPresentationError, } from "./sshEnvironment.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; @@ -88,6 +93,22 @@ describe("SSH environment IPC", () => { }).pipe(Effect.provide(layer)); }); + it("presents legacy process causes without weakening structured errors", () => { + const cause = new Error("ssh executable was not found"); + const structured = new SshCommandSpawnError({ + command: ["ssh"], + exitCode: null, + stderr: "", + target: "devbox", + cause, + }); + + const presentation = toDesktopSshOperationPresentationError(structured); + assert.equal(structured.message, "Failed to spawn SSH command for devbox."); + assert.equal(presentation.message, cause.message); + assert.strictEqual(presentation.cause, structured); + }); + it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; const layer = makeHttpClientLayer((request) => diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index a7afa1be90b..ced1ab739fa 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -26,7 +26,13 @@ import { AuthSessionState, AuthWebSocketTicketResult, } from "@t3tools/contracts"; -import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { + SshCommandExecutionError, + SshCommandSpawnError, + SshHttpBridgeError, + SshTunnelMonitorError, + SshTunnelSpawnError, +} from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -58,6 +64,23 @@ const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbi const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); +const isSshCausePresentationError = Schema.is( + Schema.Union([ + SshCommandSpawnError, + SshCommandExecutionError, + SshTunnelSpawnError, + SshTunnelMonitorError, + ]), +); + +export function toDesktopSshOperationPresentationError( + error: DesktopSshEnvironment.DesktopSshEnvironmentOperationError, +): DesktopSshEnvironment.DesktopSshEnvironmentOperationError | Error { + if (isSshCausePresentationError(error) && error.cause instanceof Error) { + return new Error(error.cause.message, { cause: error }); + } + return error; +} function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) { @@ -146,6 +169,7 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ ) : Effect.fail(error), }), + Effect.mapError(toDesktopSshOperationPresentationError), ); }), }); @@ -156,7 +180,9 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - yield* sshEnvironment.disconnectEnvironment(target); + yield* sshEnvironment + .disconnectEnvironment(target) + .pipe(Effect.mapError(toDesktopSshOperationPresentationError)); }), }); diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 7767ae7daec..82c5926f5ab 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -182,7 +182,8 @@ describe("ssh command", () => { cause: spawnCause, }); assert.strictEqual(spawnError.cause, spawnCause); - assert.equal(spawnError.message, "ssh executable was not found"); + assert.equal(spawnError.message, "Failed to spawn SSH command for devbox."); + assert.notInclude(spawnError.message, spawnCause.message); }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index f053d960f1b..5df0534cd04 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -73,7 +73,7 @@ export class SshCommandSpawnError extends Schema.TaggedErrorClass { assert.isTrue(Result.isFailure(invalid)); if (Result.isFailure(invalid)) { assert.instanceOf(invalid.failure, SshHttpBridgeInvalidUrlError); - assert.equal(invalid.failure.message, "Invalid URL"); + assert.equal(invalid.failure.message, "Invalid SSH forwarded http base URL."); } const nonLoopback = yield* Effect.result( From 19167323bbc3104605190587f6e0d7766b433519 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:00:51 -0700 Subject: [PATCH 04/26] Fix SSH auth retries through structured causes Co-authored-by: codex --- packages/ssh/src/auth.ts | 22 +++++++++++-- packages/ssh/src/tunnel.test.ts | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index 30f591b7471..e11b5e355cd 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -205,8 +205,7 @@ export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnviron }; }); -export function isSshAuthFailure(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); +function isSshAuthFailureMessage(message: string): boolean { const normalized = message.toLowerCase(); return ( /permission denied \((?:publickey|password|keyboard-interactive|hostbased|gssapi-with-mic)[^)]*\)/u.test( @@ -216,3 +215,22 @@ export function isSshAuthFailure(error: unknown): boolean { /too many authentication failures/u.test(normalized) ); } + +export function isSshAuthFailure(error: unknown): boolean { + const visited = new Set(); + let current = error; + + while (!visited.has(current)) { + visited.add(current); + const message = current instanceof Error ? current.message : String(current); + if (isSshAuthFailureMessage(message)) { + return true; + } + if (!(current instanceof Error) || current.cause === undefined) { + return false; + } + current = current.cause; + } + + return false; +} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1c957594569..5348c204b82 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -5,6 +5,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -396,6 +397,62 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("retries authentication when a spawn error wraps an SSH auth failure", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const authFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "ssh", + description: "Permission denied (publickey,password).", + }); + const promptAttempts: number[] = []; + let launchAttempts = 0; + const spawner = ChildProcessSpawner.make((command) => { + const args = commandArgs(command); + if (args.includes("sh") && args.includes("--")) { + launchAttempts += 1; + if (launchAttempts === 1) { + return Effect.fail(authFailure); + } + return Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')); + } + if (args.includes("-N")) { + return Effect.succeed(makeRunningProcess(() => {})); + } + return Effect.succeed(makeSuccessfulProcess("\n")); + }); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.layer({ + isAvailable: true, + request: (request) => + Effect.sync(() => { + promptAttempts.push(request.attempt); + return "secret"; + }), + }), + SshTunnel.layer(), + ); + + return Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const environment = yield* manager.ensureEnvironment(target); + + assert.equal(environment.remotePort, 3773); + assert.equal(launchAttempts, 2); + assert.deepEqual(promptAttempts, [1]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.effect("closes the tunnel scope and starts fresh after disconnect", () => { const spawnedCommands: Array> = []; let tunnelKillCount = 0; From c631c70ceb9ac420f8b99ae6a878cf9bbd0e0115 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:40:58 -0700 Subject: [PATCH 05/26] Limit SSH auth retries to process causes Co-authored-by: codex --- packages/ssh/src/auth.test.ts | 34 ++++++++++++++++++++++++++++++++++ packages/ssh/src/auth.ts | 16 +++++++++++++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 3cd42ad4382..a401e6f3553 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -12,6 +12,7 @@ import { buildSshChildEnvironment, isSshAuthFailure, } from "./auth.ts"; +import * as SshErrors from "./errors.ts"; describe("ssh auth", () => { it.effect("detects ssh auth failures from common permission denied messages", () => @@ -30,6 +31,39 @@ describe("ssh auth", () => { }), ); + it("only follows causes from SSH process wrappers", () => { + const authFailure = new Error("Permission denied (publickey,password)."); + const commandFailure = new SshErrors.SshCommandSpawnError({ + command: ["ssh", "devbox"], + exitCode: null, + stderr: "", + target: "devbox", + cause: authFailure, + }); + assert.equal(isSshAuthFailure(commandFailure), true); + + const helperFailure = new SshErrors.SshAuthenticationHelperError({ + command: ["ssh", "devbox"], + exitCode: null, + stderr: "", + target: "devbox", + cause: authFailure, + }); + assert.equal(isSshAuthFailure(helperFailure), false); + + const readinessFailure = new SshErrors.SshReadinessTimeoutError({ + baseUrl: "http://127.0.0.1:41773/", + requestUrl: "http://127.0.0.1:41773/ready", + timeoutMs: 1_000, + attempts: 1, + cause: new SshErrors.SshReadinessProbeError({ + requestUrl: "http://127.0.0.1:41773/ready", + cause: new Error("HTTP authentication failed."), + }), + }); + assert.equal(isSshAuthFailure(readinessFailure), false); + }); + it.effect("creates askpass env for cached password prompts", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index e11b5e355cd..b289ffc0053 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -7,8 +7,9 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; -import type { SshPasswordPromptError } from "./errors.ts"; +import * as SshErrors from "./errors.ts"; export interface SshPasswordRequest { readonly destination: string; @@ -40,7 +41,7 @@ export class SshPasswordPrompt extends Context.Service< readonly isAvailable: boolean; readonly request: ( request: SshPasswordRequest, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/ssh/auth/SshPasswordPrompt") {} @@ -216,6 +217,15 @@ function isSshAuthFailureMessage(message: string): boolean { ); } +const isSshAuthFailureCauseWrapper = Schema.is( + Schema.Union([ + SshErrors.SshCommandSpawnError, + SshErrors.SshCommandExecutionError, + SshErrors.SshTunnelSpawnError, + SshErrors.SshTunnelMonitorError, + ]), +); + export function isSshAuthFailure(error: unknown): boolean { const visited = new Set(); let current = error; @@ -226,7 +236,7 @@ export function isSshAuthFailure(error: unknown): boolean { if (isSshAuthFailureMessage(message)) { return true; } - if (!(current instanceof Error) || current.cause === undefined) { + if (!isSshAuthFailureCauseWrapper(current)) { return false; } current = current.cause; From 71558c1e043fabd8610d7af62b7452ed98bde99a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:41:48 -0700 Subject: [PATCH 06/26] Use exhaustive relay install error handling Co-authored-by: codex --- apps/server/src/ws.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index eb99f0a8abf..1b202bd36ba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1320,7 +1320,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => status, }), ), - Effect.catchIf(RelayClient.isRelayClientInstallError, (error) => + Effect.catch((error) => Queue.fail( queue, new RelayClientInstallFailedError({ From 8f0c480d4a3d1d73c3fda9d1f15de5ef7c6209ce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:41:48 -0700 Subject: [PATCH 07/26] Inline SSH tunnel cancellation error Co-authored-by: codex --- packages/ssh/src/tunnel.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 32935fd1f49..eb77c9cee26 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -115,15 +115,6 @@ type SshEnvironmentEffectError = | SshPasswordPromptError | NetService.NetError; -function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { - return new SshCommandCancelledError({ - command: ["ssh"], - exitCode: null, - stderr: "", - target: target.alias || target.hostname, - }); -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -1271,7 +1262,15 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* return; } pendingTunnelEntries.delete(key); - yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); + yield* Deferred.fail( + pending, + new SshCommandCancelledError({ + command: ["ssh"], + exitCode: null, + stderr: "", + target: target.alias || target.hostname, + }), + ).pipe(Effect.ignore); }); yield* Scope.addFinalizer( From f8f9364b0c55f108c0352ff8ac91226a0dc06f48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:48:42 -0700 Subject: [PATCH 08/26] Keep relay install error boundary explicit Co-authored-by: codex --- apps/server/src/ws.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1b202bd36ba..7f483f83577 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1314,21 +1314,22 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => relayClient .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) .pipe( + Effect.mapError( + (error) => + new RelayClientInstallFailedError({ + reason: relayClientInstallFailureReason(error), + message: error.message, + }), + ), Effect.flatMap((status) => Queue.offer(queue, { type: "complete", status, }), ), - Effect.catch((error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: relayClientInstallFailureReason(error), - message: error.message, - }), - ), - ), + Effect.catchTags({ + RelayClientInstallFailedError: (error) => Queue.fail(queue, error), + }), Effect.andThen(Queue.end(queue)), Effect.forkScoped, ), From 2fc3ddda752ad216abfe977dc9fe574c82511c5c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 06:56:30 -0700 Subject: [PATCH 09/26] Use exhaustive relay client error handling Co-authored-by: codex --- packages/shared/src/relayClient.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index ad806457996..4c28c10af1b 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -585,14 +585,15 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ); yield* report("waiting_for_lock"); yield* acquireInstallLock(lockPath).pipe( - Effect.catchTag("PlatformError", (cause) => - Effect.fail( - new RelayClientInstallLockAcquireError({ - lockPath, - cause, - }), - ), - ), + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new RelayClientInstallLockAcquireError({ + lockPath, + cause, + }), + ), + }), ); return yield* Effect.gen(function* () { const afterLock = yield* resolve; From 1b75e73129786ffe81a47cacf020fa6e05c8d984 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:18:46 -0700 Subject: [PATCH 10/26] Inline relay install error mappings Co-authored-by: codex --- packages/shared/src/relayClient.ts | 42 ++++++++++++------------------ 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 4c28c10af1b..501a7c05ead 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -363,15 +363,6 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } -const wrapInstallFailure = - ( - makeError: (cause: unknown) => RelayClientInstallError, - ): (( - effect: Effect.Effect, - ) => Effect.Effect) => - (effect) => - effect.pipe(Effect.mapError(makeError)); - export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( options: CloudflaredRelayClientOptions, ): Effect.fn.Return< @@ -575,7 +566,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const managedDirectory = path.dirname(managedPath); const lockPath = `${managedPath}.lock`; yield* fileSystem.makeDirectory(managedDirectory, { recursive: true }).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientDirectoryCreateError({ directoryPath: managedDirectory, @@ -610,7 +601,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const download = yield* downloadAsset(releaseAsset, report); yield* report("installing"); yield* fileSystem.writeFile(archivePath, download).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientDownloadWriteError({ archivePath, @@ -622,7 +613,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const executablePath = path.join(tempDirectory, executableFileName(platform)); if (releaseAsset.archive === "tgz") { yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientArchiveExtractError({ archivePath, @@ -634,7 +625,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } if (platform !== "win32") { yield* fileSystem.chmod(executablePath, 0o755).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientExecutablePermissionError({ executablePath, @@ -645,7 +636,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } yield* report("validating"); yield* runCommand(executablePath, ["--version"]).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientExecutableValidationError({ executablePath, @@ -657,7 +648,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`; yield* report("activating"); yield* fileSystem.rename(executablePath, stagedPath).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientStageError({ sourcePath: executablePath, @@ -667,7 +658,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function ), ); yield* fileSystem.rename(stagedPath, managedPath).pipe( - wrapInstallFailure( + Effect.mapError( (cause) => new RelayClientActivationError({ sourcePath: stagedPath, @@ -686,15 +677,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }).pipe( Effect.scoped, Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), - Effect.catch((cause) => - isRelayClientInstallError(cause) - ? Effect.fail(cause) - : Effect.fail( - new RelayClientInstallWriteError({ - managedPath, - cause, - }), - ), + Effect.catch( + (cause): Effect.Effect => + isRelayClientInstallError(cause) + ? Effect.fail(cause) + : Effect.fail( + new RelayClientInstallWriteError({ + managedPath, + cause, + }), + ), ), ); }); From 101ea19f1cea98c74c91bc2ad3b4a3446a45ae44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:48:17 -0700 Subject: [PATCH 11/26] Preserve SSH readiness diagnostics Co-authored-by: codex --- packages/shared/src/shell.ts | 4 +++- packages/ssh/src/tunnel.test.ts | 19 +++++++++++++++++++ packages/ssh/src/tunnel.ts | 6 ++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index cf2f2417ff4..d0aaa821680 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -604,7 +604,9 @@ export const isCommandAvailable = Effect.fn("shell.isCommandAvailable")(function ) { return yield* resolveCommandPath(command, options).pipe( Effect.as(true), - Effect.catchTag("CommandResolutionError", () => Effect.succeed(false)), + Effect.catchTags({ + CommandResolutionError: () => Effect.succeed(false), + }), ); }); diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 5348c204b82..93604718260 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -342,6 +342,25 @@ describe("ssh tunnel scripts", () => { ); }); + it("preserves structured readiness attributes in diagnostic output", () => { + assert.deepEqual( + SshTunnel.describeReadinessCause( + new SshReadinessProbeTimeoutError({ + requestUrl: "http://127.0.0.1:41773/ready", + timeoutMs: 250, + attempt: 3, + }), + ), + { + _tag: "SshReadinessProbeTimeoutError", + requestUrl: "http://127.0.0.1:41773/ready", + timeoutMs: 250, + attempt: 3, + message: "Backend readiness probe exceeded 250ms at http://127.0.0.1:41773/ready.", + }, + ); + }); + it.effect("accepts pretty-printed pairing JSON from the remote CLI", () => { const target = { alias: "devbox", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index eb77c9cee26..0df9c65561a 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -244,9 +244,11 @@ function applyScriptPlaceholders( export function describeReadinessCause(cause: unknown): unknown { if (isSshReadinessError(cause)) { - const nestedCause = "cause" in cause ? cause.cause : undefined; + const { cause: nestedCause, ...attributes } = cause as SshReadinessError & { + readonly cause?: unknown; + }; return { - _tag: cause._tag, + ...attributes, message: cause.message, ...(nestedCause === undefined ? {} : { cause: describeReadinessCause(nestedCause) }), }; From 4b8a5547d40766da478a400ffc7c83e5d80d4ce3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:18:31 -0700 Subject: [PATCH 12/26] fix: bound SSH failure diagnostics Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 5 +- packages/ssh/src/auth.test.ts | 19 +-- packages/ssh/src/command.test.ts | 30 +++-- packages/ssh/src/command.ts | 69 ++++++----- packages/ssh/src/errors.ts | 49 ++++---- packages/ssh/src/tunnel.test.ts | 54 +++++++-- packages/ssh/src/tunnel.ts | 111 +++++++++++------- 7 files changed, 213 insertions(+), 124 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index acadcf3f46c..e776b49bb3d 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -96,9 +96,10 @@ describe("SSH environment IPC", () => { it("presents legacy process causes without weakening structured errors", () => { const cause = new Error("ssh executable was not found"); const structured = new SshCommandSpawnError({ - command: ["ssh"], + command: "ssh", + argumentCount: 0, exitCode: null, - stderr: "", + stderrBytes: 0, target: "devbox", cause, }); diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index a401e6f3553..6da2841a2a5 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -34,30 +34,35 @@ describe("ssh auth", () => { it("only follows causes from SSH process wrappers", () => { const authFailure = new Error("Permission denied (publickey,password)."); const commandFailure = new SshErrors.SshCommandSpawnError({ - command: ["ssh", "devbox"], + command: "ssh", + argumentCount: 1, exitCode: null, - stderr: "", + stderrBytes: 0, target: "devbox", cause: authFailure, }); assert.equal(isSshAuthFailure(commandFailure), true); const helperFailure = new SshErrors.SshAuthenticationHelperError({ - command: ["ssh", "devbox"], + command: "ssh", + argumentCount: 1, exitCode: null, - stderr: "", + stderrBytes: 0, target: "devbox", cause: authFailure, }); assert.equal(isSshAuthFailure(helperFailure), false); const readinessFailure = new SshErrors.SshReadinessTimeoutError({ - baseUrl: "http://127.0.0.1:41773/", - requestUrl: "http://127.0.0.1:41773/ready", + baseTarget: "http://127.0.0.1:41773/", + baseUrlLength: 23, + requestTarget: "http://127.0.0.1:41773/ready", + requestUrlLength: 28, timeoutMs: 1_000, attempts: 1, cause: new SshErrors.SshReadinessProbeError({ - requestUrl: "http://127.0.0.1:41773/ready", + requestTarget: "http://127.0.0.1:41773/ready", + requestUrlLength: 28, cause: new Error("HTTP authentication failed."), }), }); diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index 82c5926f5ab..e25b2019709 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -145,7 +145,7 @@ describe("ssh command", () => { }), ); - it.effect("includes stdout in non-zero command failures when stderr is empty", () => { + it.effect("records bounded output diagnostics for non-zero command failures", () => { const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedProcess({ stdout: "Pairing token creation failed\n" })), ); @@ -168,16 +168,24 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshCommandExitError); - assert.equal(result.failure.message, "Pairing token creation failed"); - assert.equal(result.failure.stdout, "Pairing token creation failed\n"); - assert.equal(result.failure.stderr, ""); + assert.equal(result.failure.message, "SSH command failed for julius@devbox (exit 1)."); + assert.equal(result.failure.command, "ssh"); + assert.equal(result.failure.argumentCount, 9); + assert.equal( + result.failure.stdoutBytes, + encoder.encode("Pairing token creation failed\n").length, + ); + assert.equal(result.failure.stderrBytes, 0); + assert.isFalse("stdout" in result.failure); + assert.isFalse("stderr" in result.failure); } const spawnCause = new Error("ssh executable was not found"); const spawnError = new SshCommandSpawnError({ - command: ["ssh"], + command: "ssh", + argumentCount: 0, exitCode: null, - stderr: "", + stderrBytes: 0, target: "devbox", cause: spawnCause, }); @@ -187,7 +195,7 @@ describe("ssh command", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("redacts credentials from stdout in non-zero command failures", () => { + it.effect("does not expose credentials from non-zero command output", () => { const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeFailedProcess({ stdout: '{"credential":"pairing-secret"}\n' })), ); @@ -210,8 +218,12 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshCommandExitError); - assert.equal(result.failure.message, '{"credential":"[redacted]"}'); - assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n'); + assert.notInclude(result.failure.message, "pairing-secret"); + assert.equal( + result.failure.stdoutBytes, + encoder.encode('{"credential":"pairing-secret"}\n').length, + ); + assert.isFalse("stdout" in result.failure); } }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 1872e863001..29a1ad232ef 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -27,7 +27,6 @@ import { const PUBLISHABLE_T3_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u; const DEFAULT_SSH_COMMAND_TIMEOUT_MS = 60_000; -const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; /** * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always @@ -41,6 +40,10 @@ export const resolveSshCommand = Effect.map(HostProcessPlatform, sshCommandForPl const encoder = new TextEncoder(); +export function utf8ByteLength(value: string): number { + return encoder.encode(value).byteLength; +} + export interface SshCommandResult { readonly stdout: string; readonly stderr: string; @@ -145,16 +148,6 @@ export const collectProcessOutput = ( ), ); -function redactSshErrorOutput(output: string): string { - const redacted = output.replace( - /("(?:access_token|bearerToken|credential|pairingToken|token)"\s*:\s*")[^"]+(")/giu, - "$1[redacted]$2", - ); - return redacted.length > MAX_SSH_ERROR_OUTPUT_LENGTH - ? `${redacted.slice(0, MAX_SSH_ERROR_OUTPUT_LENGTH)}\n[truncated]` - : redacted; -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -185,9 +178,11 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshAuthenticationHelperError({ - command: ["ssh"], + command: "ssh", + argumentCount: 0, exitCode: null, - stderr: "", + stderrBytes: 0, + target: hostSpec, cause, }), ), @@ -204,7 +199,8 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func const sshCommand = yield* resolveSshCommand; yield* Effect.logDebug("ssh.command.start", { ...sshTargetLogFields(target), - command: [sshCommand, ...args], + command: sshCommand, + argumentCount: args.length, hasStdin: input.stdin !== undefined, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); @@ -224,9 +220,10 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshCommandSpawnError({ - command: [sshCommand, ...args], + command: sshCommand, + argumentCount: args.length, exitCode: null, - stderr: "", + stderrBytes: 0, target: hostSpec, cause, }), @@ -244,9 +241,10 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshCommandExecutionError({ - command: ["ssh", ...args], + command: sshCommand, + argumentCount: args.length, exitCode: null, - stderr: "", + stderrBytes: 0, target: hostSpec, cause, }), @@ -254,26 +252,28 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ); if (exitCode !== 0) { - const diagnosticStdout = redactSshErrorOutput(stdout); yield* Effect.logWarning("ssh.command.failed", { ...sshTargetLogFields(target), - command: ["ssh", ...args], + command: sshCommand, + argumentCount: args.length, exitCode, - stdout: diagnosticStdout, - stderr, + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), }); return yield* new SshCommandExitError({ - command: ["ssh", ...args], + command: sshCommand, + argumentCount: args.length, exitCode, - stdout: diagnosticStdout, - stderr, + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), target: hostSpec, }); } yield* Effect.logDebug("ssh.command.succeeded", { ...sshTargetLogFields(target), - command: ["ssh", ...args], + command: sshCommand, + argumentCount: args.length, }); return { stdout, stderr }; }); @@ -298,14 +298,23 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( yield* Effect.logWarning("ssh.command.timedOut", { ...sshTargetLogFields(target), timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, - remoteCommandArgs: input.remoteCommandArgs ?? [], - preHostArgs: input.preHostArgs ?? [], + argumentCount: + baseSshArgs(target).length + + (input.preHostArgs?.length ?? 0) + + 1 + + (input.remoteCommandArgs?.length ?? 0), hasStdin: input.stdin !== undefined, }); return yield* new SshCommandTimeoutError({ - command: ["ssh"], + command: "ssh", + argumentCount: + baseSshArgs(target).length + + (input.preHostArgs?.length ?? 0) + + 1 + + (input.remoteCommandArgs?.length ?? 0), exitCode: null, - stderr: "", + stderrBytes: 0, + target: target.alias || target.hostname, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); }), diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index 5df0534cd04..a376125ab40 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -42,10 +42,11 @@ export const SshInvalidTargetError = Schema.Union([ export type SshInvalidTargetError = typeof SshInvalidTargetError.Type; const SshCommandErrorFields = { - command: Schema.Array(Schema.String), + command: Schema.String, + argumentCount: Schema.Number, exitCode: Schema.NullOr(Schema.Number), - stderr: Schema.String, - stdout: Schema.optional(Schema.String), + stderrBytes: Schema.Number, + stdoutBytes: Schema.optional(Schema.Number), target: Schema.optional(Schema.String), timeoutMs: Schema.optional(Schema.Number), }; @@ -95,11 +96,7 @@ export class SshCommandExitError extends Schema.TaggedErrorClass()( "SshLaunchPortMissingError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, }, ) { override get message(): string { @@ -189,7 +182,7 @@ export class SshLaunchPortMissingError extends Schema.TaggedErrorClass()( "SshLaunchOutputParseError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, cause: Schema.Defect(), }, ) { @@ -201,7 +194,7 @@ export class SshLaunchOutputParseError extends Schema.TaggedErrorClass()( "SshLaunchInvalidPortError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, remotePort: Schema.optional(Schema.Number), }, ) { @@ -220,7 +213,7 @@ export type SshLaunchError = typeof SshLaunchError.Type; export class SshPairingCredentialMissingError extends Schema.TaggedErrorClass()( "SshPairingCredentialMissingError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, }, ) { override get message(): string { @@ -231,7 +224,7 @@ export class SshPairingCredentialMissingError extends Schema.TaggedErrorClass()( "SshPairingOutputParseError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, cause: Schema.Defect(), }, ) { @@ -243,7 +236,7 @@ export class SshPairingOutputParseError extends Schema.TaggedErrorClass()( "SshPairingInvalidCredentialError", { - stdout: Schema.String, + stdoutBytes: Schema.Number, }, ) { override get message(): string { @@ -299,40 +292,44 @@ export type SshHttpBridgeError = typeof SshHttpBridgeError.Type; export class SshReadinessProbeError extends Schema.TaggedErrorClass()( "SshReadinessProbeError", { - requestUrl: Schema.String, + requestTarget: Schema.String, + requestUrlLength: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return `Backend readiness probe failed at ${this.requestUrl}.`; + return `Backend readiness probe failed at ${this.requestTarget}.`; } } export class SshReadinessProbeTimeoutError extends Schema.TaggedErrorClass()( "SshReadinessProbeTimeoutError", { - requestUrl: Schema.String, + requestTarget: Schema.String, + requestUrlLength: Schema.Number, timeoutMs: Schema.Number, attempt: Schema.Number, }, ) { override get message(): string { - return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${this.requestUrl}.`; + return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${this.requestTarget}.`; } } export class SshReadinessTimeoutError extends Schema.TaggedErrorClass()( "SshReadinessTimeoutError", { - baseUrl: Schema.String, - requestUrl: Schema.String, + baseTarget: Schema.String, + baseUrlLength: Schema.Number, + requestTarget: Schema.String, + requestUrlLength: Schema.Number, timeoutMs: Schema.Number, attempts: Schema.Number, cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${this.baseUrl}.`; + return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${this.baseTarget}.`; } } diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 93604718260..aa451ce4573 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -20,6 +20,7 @@ import { SshHttpBridgeInvalidUrlError, SshHttpBridgeMissingUrlError, SshHttpBridgeNonLoopbackUrlError, + SshPairingOutputParseError, SshReadinessProbeTimeoutError, SshReadinessTimeoutError, } from "./errors.ts"; @@ -259,8 +260,8 @@ describe("ssh tunnel scripts", () => { const fiber = yield* Effect.forkChild( Effect.result( SshTunnel.waitForHttpReady({ - baseUrl: "http://127.0.0.1:41773/", - path: "/ready", + baseUrl: "http://127.0.0.1:41773/?token=base-secret#fragment", + path: "/ready?credential=request-secret#fragment", timeoutMs: 1_000, intervalMs: 100, probeTimeoutMs: 250, @@ -276,8 +277,13 @@ describe("ssh tunnel scripts", () => { if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshReadinessTimeoutError); const timeoutError = result.failure as SshReadinessTimeoutError; - assert.equal(timeoutError.baseUrl, "http://127.0.0.1:41773/"); - assert.equal(timeoutError.requestUrl, "http://127.0.0.1:41773/ready"); + assert.equal(timeoutError.baseTarget, "http://127.0.0.1:41773/"); + assert.equal(timeoutError.requestTarget, "http://127.0.0.1:41773/ready"); + assert.isAbove(timeoutError.baseUrlLength, timeoutError.baseTarget.length); + assert.isAbove(timeoutError.requestUrlLength, timeoutError.requestTarget.length); + assert.isFalse("baseUrl" in timeoutError); + assert.isFalse("requestUrl" in timeoutError); + assert.notInclude(timeoutError.message, "secret"); assert.equal( timeoutError.message, "Timed out waiting 1000ms for backend readiness at http://127.0.0.1:41773/.", @@ -325,7 +331,7 @@ describe("ssh tunnel scripts", () => { }), ); - it("preserves primitive readiness reason values in diagnostic output", () => { + it("describes readiness causes without copying arbitrary messages", () => { assert.deepEqual( SshTunnel.describeReadinessCause({ _tag: "HttpClientError", @@ -335,9 +341,8 @@ describe("ssh tunnel scripts", () => { }), { _tag: "HttpClientError", - message: "Backend readiness probe failed.", - reason: "authentication failed", - cause: "upstream closed", + reason: { type: "string" }, + cause: { type: "string" }, }, ); }); @@ -346,14 +351,16 @@ describe("ssh tunnel scripts", () => { assert.deepEqual( SshTunnel.describeReadinessCause( new SshReadinessProbeTimeoutError({ - requestUrl: "http://127.0.0.1:41773/ready", + requestTarget: "http://127.0.0.1:41773/ready", + requestUrlLength: 28, timeoutMs: 250, attempt: 3, }), ), { _tag: "SshReadinessProbeTimeoutError", - requestUrl: "http://127.0.0.1:41773/ready", + requestTarget: "http://127.0.0.1:41773/ready", + requestUrlLength: 28, timeoutMs: 250, attempt: 3, message: "Backend readiness probe exceeded 250ms at http://127.0.0.1:41773/ready.", @@ -416,6 +423,33 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect("does not copy pairing command output into parse errors", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const output = '{"credential":"pairing-secret"'; + const spawner = ChildProcessSpawner.make(() => Effect.succeed(makeSuccessfulProcess(output))); + const processLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + return Effect.gen(function* () { + const result = yield* Effect.result(SshTunnel.issueRemotePairingToken(target)); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshPairingOutputParseError); + assert.equal(result.failure.stdoutBytes, new TextEncoder().encode(output).length); + assert.isFalse("stdout" in result.failure); + assert.notInclude(result.failure.message, "pairing-secret"); + assert.exists(result.failure.cause); + } + }).pipe(Effect.provide(processLayer)); + }); + it.effect("retries authentication when a spawn error wraps an SSH auth failure", () => { const target = { alias: "devbox", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 0df9c65561a..61156ad493a 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -35,6 +35,7 @@ import { resolveSshTarget, runSshCommand, targetConnectionKey, + utf8ByteLength, } from "./command.ts"; import { SshAuthenticationHelperError, @@ -256,23 +257,25 @@ export function describeReadinessCause(cause: unknown): unknown { if (cause instanceof Error) { return { name: cause.name, - message: cause.message, ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), }; } if (typeof cause !== "object" || cause === null) { - return cause; + return { type: cause === null ? "null" : typeof cause }; } const record = cause as Readonly>; return { ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), - ...(typeof record.message === "string" ? { message: record.message } : {}), ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), }; } +function readinessUrlTarget(url: URL): string { + return `${url.origin}${url.pathname}`; +} + export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); const net = require("node:net"); const filePath = process.argv[2] ?? ""; @@ -748,21 +751,21 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo }); if (!getLastNonEmptyOutputLine(result.stdout)) { return yield* new SshLaunchPortMissingError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), }); } const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe( Effect.mapError( (cause) => new SshLaunchOutputParseError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), cause, }), ), ); if (!Number.isInteger(parsed.remotePort)) { return yield* new SshLaunchInvalidPortError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), remotePort: parsed.remotePort, }); } @@ -803,21 +806,21 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT }); if (!getLastNonEmptyOutputLine(result.stdout)) { return yield* new SshPairingCredentialMissingError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), }); } const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe( Effect.mapError( (cause) => new SshPairingOutputParseError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), cause, }), ), ); if (parsed.credential.trim().length === 0) { return yield* new SshPairingInvalidCredentialError({ - stdout: result.stdout, + stdoutBytes: utf8ByteLength(result.stdout), }); } yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", { @@ -886,14 +889,22 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio const retryPolicy = Schedule.spaced(Duration.millis(intervalMs)).pipe( Schedule.take(Math.max(0, Math.ceil(timeoutMs / intervalMs))), ); - const requestUrl = new URL(input.path ?? "/", input.baseUrl).toString(); + const baseUrl = new URL(input.baseUrl); + const request = new URL(input.path ?? "/", baseUrl); + const requestUrl = request.toString(); + const baseTarget = readinessUrlTarget(baseUrl); + const requestTarget = readinessUrlTarget(request); + const baseUrlLength = utf8ByteLength(input.baseUrl); + const requestUrlLength = utf8ByteLength(requestUrl); const client = yield* HttpClient.HttpClient; const lastProbeFailure = yield* Ref.make(null); let attempt = 0; yield* Effect.logDebug("ssh.tunnel.httpReady.start", { - baseUrl: input.baseUrl, - requestUrl, + baseTarget, + baseUrlLength, + requestTarget, + requestUrlLength, timeoutMs, intervalMs, probeTimeoutMs, @@ -909,7 +920,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio Effect.mapError( (cause) => new SshReadinessProbeError({ - requestUrl, + requestTarget, + requestUrlLength, cause, }), ), @@ -919,7 +931,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio onNone: () => Effect.fail( new SshReadinessProbeTimeoutError({ - requestUrl, + requestTarget, + requestUrlLength, timeoutMs: probeTimeoutMs, attempt, }), @@ -930,7 +943,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio isSshReadinessError(cause) ? cause : new SshReadinessProbeError({ - requestUrl, + requestTarget, + requestUrlLength, cause, }), ), @@ -946,7 +960,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio isSshReadinessError(cause) ? cause : new SshReadinessProbeError({ - requestUrl, + requestTarget, + requestUrlLength, cause, }), ), @@ -956,16 +971,20 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio return yield* Option.match(result, { onSome: () => Effect.logDebug("ssh.tunnel.httpReady.succeeded", { - baseUrl: input.baseUrl, - requestUrl, + baseTarget, + baseUrlLength, + requestTarget, + requestUrlLength, attempts: attempt, }), onNone: () => Effect.gen(function* () { const lastFailure = yield* Ref.get(lastProbeFailure); yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { - baseUrl: input.baseUrl, - requestUrl, + baseTarget, + baseUrlLength, + requestTarget, + requestUrlLength, timeoutMs, intervalMs, probeTimeoutMs, @@ -973,8 +992,10 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio lastFailure: describeReadinessCause(lastFailure), }); return yield* new SshReadinessTimeoutError({ - baseUrl: input.baseUrl, - requestUrl, + baseTarget, + baseUrlLength, + requestTarget, + requestUrlLength, timeoutMs, attempts: attempt, ...(lastFailure === null ? {} : { cause: lastFailure }), @@ -1045,9 +1066,11 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: Effect.mapError( (cause) => new SshAuthenticationHelperError({ - command: ["ssh"], + command: "ssh", + argumentCount: 0, exitCode: null, - stderr: "", + stderrBytes: 0, + target: hostSpec, cause, }), ), @@ -1069,12 +1092,12 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: hostSpec, ]; const sshCommand = yield* resolveSshCommand; - const tunnelCommand = [sshCommand, ...args]; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scope = yield* Scope.Scope; yield* Effect.logDebug("ssh.tunnel.spawn.start", { ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, localPort: input.localPort, remotePort: input.remotePort, remoteServerKind: input.remoteServerKind, @@ -1095,9 +1118,10 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: Effect.mapError( (cause) => new SshTunnelSpawnError({ - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, exitCode: null, - stderr: "", + stderrBytes: 0, target: input.resolvedTarget.alias, cause, }), @@ -1105,7 +1129,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ); yield* Effect.logDebug("ssh.tunnel.spawn.succeeded", { ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, @@ -1129,29 +1154,32 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: Effect.mapError( (cause) => new SshTunnelMonitorError({ - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, exitCode: null, - stderr: "", + stderrBytes: 0, target: input.resolvedTarget.alias, cause, }), ), Effect.flatMap(([stderr, exitCode]) => { const error = new SshTunnelExitError({ - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, exitCode, - stderr, + stderrBytes: utf8ByteLength(stderr), target: input.resolvedTarget.alias, }); return Effect.logWarning("ssh.tunnel.process.exited", { ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, httpBaseUrl: input.httpBaseUrl, exitCode, - stderr, + stderrBytes: utf8ByteLength(stderr), }).pipe(Effect.andThen(Effect.fail(error))); }), ); @@ -1165,7 +1193,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: Effect.tap(() => Effect.logInfo("ssh.tunnel.ready", { ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, @@ -1191,7 +1220,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: : null; yield* Effect.logWarning("ssh.tunnel.ready.failed", { ...sshTargetLogFields(input.resolvedTarget), - command: tunnelCommand, + command: sshCommand, + argumentCount: args.length, pid: child.pid, processRunning, ...(Exit.isSuccess(processRunningExit) @@ -1204,7 +1234,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ...(Exit.isSuccess(localPortAvailableExit) ? {} : { localPortProbeError: localPortAvailableExit.cause }), - ...(remoteLogTail === null ? {} : { remoteLogTail }), + ...(remoteLogTail === null ? {} : { remoteLogTailBytes: utf8ByteLength(remoteLogTail) }), ...(Exit.isSuccess(remoteLogTailExit) ? {} : { remoteLogTailError: remoteLogTailExit.cause }), @@ -1267,9 +1297,10 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* yield* Deferred.fail( pending, new SshCommandCancelledError({ - command: ["ssh"], + command: "ssh", + argumentCount: 0, exitCode: null, - stderr: "", + stderrBytes: 0, target: target.alias || target.hostname, }), ).pipe(Effect.ignore); From 1d53e19db150462a9097f14aa970619a52d12fd5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:04:27 -0700 Subject: [PATCH 13/26] fix(ssh): sanitize URL diagnostics Co-authored-by: codex --- packages/shared/src/relayClient.test.ts | 48 +++++++++++++++++++++- packages/shared/src/relayClient.ts | 28 ++++++++++--- packages/ssh/src/auth.test.ts | 33 ++++++++++++--- packages/ssh/src/errors.ts | 35 +++++++++++----- packages/ssh/src/tunnel.test.ts | 52 +++++++++++++++++++----- packages/ssh/src/tunnel.ts | 54 +++++++++++-------------- 6 files changed, 185 insertions(+), 65 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index fefe7f31d31..a789a2c88a8 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -40,12 +40,12 @@ function makeHandle(exitCode = 0) { }); } -const makeHttpClientLayer = (bytes: Uint8Array) => +const makeHttpClientLayer = (bytes: Uint8Array, status = 200) => Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer)), + HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer, { status })), ), ), ); @@ -197,6 +197,50 @@ describe("RelayClient", () => { ), ); + it.effect("keeps sensitive release URL components out of install diagnostics", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const releaseUrl = + "https://download-user:download-password@example.test/private/cloudflared?signature=secret#fragment"; + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: { + url: releaseUrl, + sha256: "00", + archive: "binary", + }, + }); + + const error = yield* manager.install.pipe(Effect.flip); + expect(error).toBeInstanceOf(RelayClient.RelayClientDownloadError); + expect(error).toMatchObject({ + urlInputLength: releaseUrl.length, + urlProtocol: "https:", + urlHostname: "example.test", + }); + expect("url" in error).toBe(false); + expect(error.cause).toBeDefined(); + expect(error.message).not.toContain("download-user"); + expect(error.message).not.toContain("download-password"); + expect(error.message).not.toContain("/private/cloudflared"); + expect(error.message).not.toContain("signature=secret"); + expect(error.message).not.toContain("fragment"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + makeHttpClientLayer(new Uint8Array(), 500), + makeSpawnerLayer([]), + hostRuntimeLayer(), + ), + ), + ), + ); + it.effect("serializes concurrent installs within one runtime", () => { const commands: Array = []; const bytes = new TextEncoder().encode("test-cloudflared-binary"); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 501a7c05ead..c35abaa5201 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -22,6 +22,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { HostProcessArchitecture, HostProcessPlatform } from "./hostProcess.ts"; +import { getUrlDiagnostics } from "./urlDiagnostics.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; @@ -48,10 +49,25 @@ export type RelayClientStatus = export type AvailableRelayClient = Extract; +const RelayClientUrlDiagnosticFields = { + urlInputLength: Schema.Number, + urlProtocol: Schema.optional(Schema.String), + urlHostname: Schema.optional(Schema.String), +}; + +function relayClientUrlDiagnostics(input: string) { + const diagnostics = getUrlDiagnostics(input); + return { + urlInputLength: diagnostics.inputLength, + ...(diagnostics.protocol === undefined ? {} : { urlProtocol: diagnostics.protocol }), + ...(diagnostics.hostname === undefined ? {} : { urlHostname: diagnostics.hostname }), + }; +} + export class RelayClientDownloadError extends Schema.TaggedErrorClass()( "RelayClientDownloadError", { - url: Schema.String, + ...RelayClientUrlDiagnosticFields, cause: Schema.Defect(), }, ) { @@ -63,7 +79,7 @@ export class RelayClientDownloadError extends Schema.TaggedErrorClass()( "RelayClientDownloadReadError", { - url: Schema.String, + ...RelayClientUrlDiagnosticFields, cause: Schema.Defect(), }, ) { @@ -121,7 +137,7 @@ export class RelayClientUnsupportedPlatformError extends Schema.TaggedErrorClass export class RelayClientChecksumVerificationError extends Schema.TaggedErrorClass()( "RelayClientChecksumVerificationError", { - url: Schema.String, + ...RelayClientUrlDiagnosticFields, expectedChecksum: Schema.String, cause: Schema.Defect(), }, @@ -481,7 +497,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function Effect.mapError( (cause) => new RelayClientDownloadError({ - url: asset.url, + ...relayClientUrlDiagnostics(asset.url), cause, }), ), @@ -491,7 +507,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function Effect.mapError( (cause) => new RelayClientDownloadReadError({ - url: asset.url, + ...relayClientUrlDiagnostics(asset.url), cause, }), ), @@ -502,7 +518,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function Effect.mapError( (cause) => new RelayClientChecksumVerificationError({ - url: asset.url, + ...relayClientUrlDiagnostics(asset.url), expectedChecksum: asset.sha256, cause, }), diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 6da2841a2a5..e0eae6de156 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -54,15 +54,36 @@ describe("ssh auth", () => { assert.equal(isSshAuthFailure(helperFailure), false); const readinessFailure = new SshErrors.SshReadinessTimeoutError({ - baseTarget: "http://127.0.0.1:41773/", - baseUrlLength: 23, - requestTarget: "http://127.0.0.1:41773/ready", - requestUrlLength: 28, + base: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 23, + pathnameLength: 1, + hasQuery: false, + hasFragment: false, + }, + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, timeoutMs: 1_000, attempts: 1, cause: new SshErrors.SshReadinessProbeError({ - requestTarget: "http://127.0.0.1:41773/ready", - requestUrlLength: 28, + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, cause: new Error("HTTP authentication failed."), }), }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index a376125ab40..1ddc635dd9f 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -289,47 +289,60 @@ export const SshHttpBridgeError = Schema.Union([ ]); export type SshHttpBridgeError = typeof SshHttpBridgeError.Type; +const SshReadinessUrlDiagnostics = Schema.Struct({ + protocol: Schema.String, + hostname: Schema.String, + port: Schema.optional(Schema.String), + urlLength: Schema.Number, + pathnameLength: Schema.Number, + hasQuery: Schema.Boolean, + hasFragment: Schema.Boolean, +}); + +type SshReadinessUrlDiagnostics = typeof SshReadinessUrlDiagnostics.Type; + +function readinessTarget(diagnostics: SshReadinessUrlDiagnostics): string { + const port = diagnostics.port === undefined ? "" : `:${diagnostics.port}`; + return `${diagnostics.protocol}//${diagnostics.hostname}${port}`; +} + export class SshReadinessProbeError extends Schema.TaggedErrorClass()( "SshReadinessProbeError", { - requestTarget: Schema.String, - requestUrlLength: Schema.Number, + request: SshReadinessUrlDiagnostics, cause: Schema.Defect(), }, ) { override get message(): string { - return `Backend readiness probe failed at ${this.requestTarget}.`; + return `Backend readiness probe failed at ${readinessTarget(this.request)}.`; } } export class SshReadinessProbeTimeoutError extends Schema.TaggedErrorClass()( "SshReadinessProbeTimeoutError", { - requestTarget: Schema.String, - requestUrlLength: Schema.Number, + request: SshReadinessUrlDiagnostics, timeoutMs: Schema.Number, attempt: Schema.Number, }, ) { override get message(): string { - return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${this.requestTarget}.`; + return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${readinessTarget(this.request)}.`; } } export class SshReadinessTimeoutError extends Schema.TaggedErrorClass()( "SshReadinessTimeoutError", { - baseTarget: Schema.String, - baseUrlLength: Schema.Number, - requestTarget: Schema.String, - requestUrlLength: Schema.Number, + base: SshReadinessUrlDiagnostics, + request: SshReadinessUrlDiagnostics, timeoutMs: Schema.Number, attempts: Schema.Number, cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${this.baseTarget}.`; + return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${readinessTarget(this.base)}.`; } } diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index aa451ce4573..198de14ab0e 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -277,16 +277,34 @@ describe("ssh tunnel scripts", () => { if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshReadinessTimeoutError); const timeoutError = result.failure as SshReadinessTimeoutError; - assert.equal(timeoutError.baseTarget, "http://127.0.0.1:41773/"); - assert.equal(timeoutError.requestTarget, "http://127.0.0.1:41773/ready"); - assert.isAbove(timeoutError.baseUrlLength, timeoutError.baseTarget.length); - assert.isAbove(timeoutError.requestUrlLength, timeoutError.requestTarget.length); + assert.deepEqual(timeoutError.base, { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 50, + pathnameLength: 1, + hasQuery: true, + hasFragment: true, + }); + assert.deepEqual(timeoutError.request, { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 63, + pathnameLength: 6, + hasQuery: true, + hasFragment: true, + }); assert.isFalse("baseUrl" in timeoutError); assert.isFalse("requestUrl" in timeoutError); + assert.isFalse("baseTarget" in timeoutError); + assert.isFalse("requestTarget" in timeoutError); assert.notInclude(timeoutError.message, "secret"); + assert.notInclude(timeoutError.message, "/ready"); + assert.notInclude(timeoutError.message, "request-secret"); assert.equal( timeoutError.message, - "Timed out waiting 1000ms for backend readiness at http://127.0.0.1:41773/.", + "Timed out waiting 1000ms for backend readiness at http://127.0.0.1:41773.", ); assert.isAbove(timeoutError.attempts, 0); assert.instanceOf(timeoutError.cause, SshReadinessProbeTimeoutError); @@ -351,19 +369,33 @@ describe("ssh tunnel scripts", () => { assert.deepEqual( SshTunnel.describeReadinessCause( new SshReadinessProbeTimeoutError({ - requestTarget: "http://127.0.0.1:41773/ready", - requestUrlLength: 28, + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, timeoutMs: 250, attempt: 3, }), ), { _tag: "SshReadinessProbeTimeoutError", - requestTarget: "http://127.0.0.1:41773/ready", - requestUrlLength: 28, + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, timeoutMs: 250, attempt: 3, - message: "Backend readiness probe exceeded 250ms at http://127.0.0.1:41773/ready.", + message: "Backend readiness probe exceeded 250ms at http://127.0.0.1:41773.", }, ); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 61156ad493a..38e09c18b4d 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -272,8 +272,16 @@ export function describeReadinessCause(cause: unknown): unknown { }; } -function readinessUrlTarget(url: URL): string { - return `${url.origin}${url.pathname}`; +function readinessUrlDiagnostics(url: URL, urlLength: number) { + return { + protocol: url.protocol, + hostname: url.hostname, + ...(url.port === "" ? {} : { port: url.port }), + urlLength, + pathnameLength: utf8ByteLength(url.pathname), + hasQuery: url.search !== "", + hasFragment: url.hash !== "", + }; } export const REMOTE_PICK_PORT_SCRIPT = `const fs = require("node:fs"); @@ -892,19 +900,15 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio const baseUrl = new URL(input.baseUrl); const request = new URL(input.path ?? "/", baseUrl); const requestUrl = request.toString(); - const baseTarget = readinessUrlTarget(baseUrl); - const requestTarget = readinessUrlTarget(request); - const baseUrlLength = utf8ByteLength(input.baseUrl); - const requestUrlLength = utf8ByteLength(requestUrl); + const base = readinessUrlDiagnostics(baseUrl, utf8ByteLength(input.baseUrl)); + const requestDiagnostics = readinessUrlDiagnostics(request, utf8ByteLength(requestUrl)); const client = yield* HttpClient.HttpClient; const lastProbeFailure = yield* Ref.make(null); let attempt = 0; yield* Effect.logDebug("ssh.tunnel.httpReady.start", { - baseTarget, - baseUrlLength, - requestTarget, - requestUrlLength, + base, + request: requestDiagnostics, timeoutMs, intervalMs, probeTimeoutMs, @@ -920,8 +924,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio Effect.mapError( (cause) => new SshReadinessProbeError({ - requestTarget, - requestUrlLength, + request: requestDiagnostics, cause, }), ), @@ -931,8 +934,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio onNone: () => Effect.fail( new SshReadinessProbeTimeoutError({ - requestTarget, - requestUrlLength, + request: requestDiagnostics, timeoutMs: probeTimeoutMs, attempt, }), @@ -943,8 +945,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio isSshReadinessError(cause) ? cause : new SshReadinessProbeError({ - requestTarget, - requestUrlLength, + request: requestDiagnostics, cause, }), ), @@ -960,8 +961,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio isSshReadinessError(cause) ? cause : new SshReadinessProbeError({ - requestTarget, - requestUrlLength, + request: requestDiagnostics, cause, }), ), @@ -971,20 +971,16 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio return yield* Option.match(result, { onSome: () => Effect.logDebug("ssh.tunnel.httpReady.succeeded", { - baseTarget, - baseUrlLength, - requestTarget, - requestUrlLength, + base, + request: requestDiagnostics, attempts: attempt, }), onNone: () => Effect.gen(function* () { const lastFailure = yield* Ref.get(lastProbeFailure); yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { - baseTarget, - baseUrlLength, - requestTarget, - requestUrlLength, + base, + request: requestDiagnostics, timeoutMs, intervalMs, probeTimeoutMs, @@ -992,10 +988,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio lastFailure: describeReadinessCause(lastFailure), }); return yield* new SshReadinessTimeoutError({ - baseTarget, - baseUrlLength, - requestTarget, - requestUrlLength, + base, + request: requestDiagnostics, timeoutMs, attempts: attempt, ...(lastFailure === null ? {} : { cause: lastFailure }), From 4e1cdad26aad8fc096e8e97bd7dda05ac177f0dd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 10:56:48 -0700 Subject: [PATCH 14/26] [codex] Redact SSH tunnel diagnostics (#3412) Co-authored-by: codex --- packages/ssh/src/command.ts | 5 +- packages/ssh/src/tunnel.test.ts | 133 +++++++++++++++++++++++++++++--- packages/ssh/src/tunnel.ts | 76 ++++++++++++------ 3 files changed, 178 insertions(+), 36 deletions(-) diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 29a1ad232ef..27b3b5164dd 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -350,7 +350,10 @@ export const resolveSshTarget = Effect.fn("ssh/command.resolveSshTarget")(functi Effect.logDebug("ssh.target.resolve.succeeded", sshTargetLogFields(target)), ), Effect.catch((cause) => - Effect.logDebug("ssh.target.resolve.fallback", { alias: trimmedAlias, cause }).pipe( + Effect.logDebug("ssh.target.resolve.fallback", { + alias: trimmedAlias, + failureTag: cause._tag, + }).pipe( Effect.as({ alias: trimmedAlias, hostname: trimmedAlias, diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 198de14ab0e..d64c9eddd11 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -5,12 +5,14 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -21,6 +23,7 @@ import { SshHttpBridgeMissingUrlError, SshHttpBridgeNonLoopbackUrlError, SshPairingOutputParseError, + SshReadinessProbeError, SshReadinessProbeTimeoutError, SshReadinessTimeoutError, } from "./errors.ts"; @@ -88,6 +91,14 @@ function commandArgs(command: ChildProcess.Command): ReadonlyArray { return command._tag === "StandardCommand" ? command.args : []; } +function flattenedLogText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return String(value); + return Object.entries(value) + .flatMap(([key, nested]) => [key, flattenedLogText(nested)]) + .join("\n"); +} + describe("ssh tunnel scripts", () => { it("builds the remote t3 runner with npx and npm fallbacks", () => { const script = SshTunnel.buildRemoteT3RunnerScript({ nodeEngineRange: TEST_NODE_ENGINE_RANGE }); @@ -255,13 +266,22 @@ describe("ssh tunnel scripts", () => { assert.include(SshTunnel.REMOTE_PICK_PORT_SCRIPT, 'const filePath = process.argv[2] ?? "";'); }); - it.effect("bounds each HTTP readiness probe so retries cannot hang on one request", () => - Effect.gen(function* () { + it.effect("bounds each HTTP readiness probe so retries cannot hang on one request", () => { + const baseUrl = + "http://ssh-user:ssh-password@127.0.0.1:41773/private/base?token=base-secret#base-fragment"; + const path = "/ready/private?credential=request-secret#request-fragment"; + const requestUrl = new URL(path, baseUrl).toString(); + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); + + return Effect.gen(function* () { const fiber = yield* Effect.forkChild( Effect.result( SshTunnel.waitForHttpReady({ - baseUrl: "http://127.0.0.1:41773/?token=base-secret#fragment", - path: "/ready?credential=request-secret#fragment", + baseUrl, + path, timeoutMs: 1_000, intervalMs: 100, probeTimeoutMs: 250, @@ -281,8 +301,8 @@ describe("ssh tunnel scripts", () => { protocol: "http:", hostname: "127.0.0.1", port: "41773", - urlLength: 50, - pathnameLength: 1, + urlLength: baseUrl.length, + pathnameLength: new TextEncoder().encode(new URL(baseUrl).pathname).length, hasQuery: true, hasFragment: true, }); @@ -290,8 +310,8 @@ describe("ssh tunnel scripts", () => { protocol: "http:", hostname: "127.0.0.1", port: "41773", - urlLength: 63, - pathnameLength: 6, + urlLength: requestUrl.length, + pathnameLength: new TextEncoder().encode(new URL(requestUrl).pathname).length, hasQuery: true, hasFragment: true, }); @@ -311,13 +331,91 @@ describe("ssh tunnel scripts", () => { const probeTimeout = timeoutError.cause as SshReadinessProbeTimeoutError; assert.equal(probeTimeout.attempt, timeoutError.attempts); assert.isFalse("cause" in probeTimeout); + + const logText = flattenedLogText(capturedLogs); + assert.include(logText, "127.0.0.1"); + assert.notInclude(logText, "ssh-user"); + assert.notInclude(logText, "ssh-password"); + assert.notInclude(logText, "/private/base"); + assert.notInclude(logText, "base-secret"); + assert.notInclude(logText, "/ready/private"); + assert.notInclude(logText, "request-secret"); + assert.notInclude(logText, "base-fragment"); + assert.notInclude(logText, "request-fragment"); } }).pipe( Effect.provide( - Layer.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, hangingHttpClient)), + Layer.mergeAll( + TestClock.layer(), + Layer.succeed(HttpClient.HttpClient, hangingHttpClient), + Logger.layer([logger], { mergeWithExisting: false }), + ), ), - ), - ); + ); + }); + + it.effect("preserves the exact HTTP transport cause without logging request secrets", () => { + const baseUrl = + "http://transport-user:transport-password@127.0.0.1:41773/private?token=transport-secret#fragment"; + const transportCause = new Error("socket closed"); + const clientErrors: Array = []; + const failingHttpClient = HttpClient.make((request) => { + const error = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: transportCause }), + }); + clientErrors.push(error); + return Effect.fail(error); + }); + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); + + return Effect.gen(function* () { + const fiber = yield* Effect.forkChild( + Effect.result( + SshTunnel.waitForHttpReady({ + baseUrl, + timeoutMs: 300, + intervalMs: 100, + probeTimeoutMs: 250, + }), + ), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(300)); + + const result = yield* Fiber.join(fiber); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshReadinessTimeoutError); + const timeoutError = result.failure as SshReadinessTimeoutError; + assert.instanceOf(timeoutError.cause, SshReadinessProbeError); + const probeError = timeoutError.cause as SshReadinessProbeError; + assert.strictEqual(probeError.cause, clientErrors.at(-1)); + assert.strictEqual( + (probeError.cause as HttpClientError.HttpClientError).reason.cause, + transportCause, + ); + + const logText = flattenedLogText(capturedLogs); + assert.include(logText, "HttpClientError"); + assert.include(logText, "TransportError"); + assert.notInclude(logText, "transport-user"); + assert.notInclude(logText, "transport-password"); + assert.notInclude(logText, "/private"); + assert.notInclude(logText, "transport-secret"); + } + }).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + Layer.succeed(HttpClient.HttpClient, failingHttpClient), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); it.effect("preserves forwarded HTTP URL error messages", () => Effect.gen(function* () { @@ -363,6 +461,10 @@ describe("ssh tunnel scripts", () => { cause: { type: "string" }, }, ); + + const cyclicCause: { readonly _tag: string; cause?: unknown } = { _tag: "CyclicCause" }; + cyclicCause.cause = cyclicCause; + assert.include(flattenedLogText(SshTunnel.describeReadinessCause(cyclicCause)), "truncated"); }); it("preserves structured readiness attributes in diagnostic output", () => { @@ -542,6 +644,10 @@ describe("ssh tunnel scripts", () => { const spawnedCommands: Array> = []; let tunnelKillCount = 0; let stopCommandCount = 0; + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); const spawner = ChildProcessSpawner.make((command) => Effect.sync(() => { const args = commandArgs(command); @@ -568,6 +674,7 @@ describe("ssh tunnel scripts", () => { Layer.succeed(NetService.NetService, testNetService), SshAuth.disabledLayer, SshTunnel.layer(), + Logger.layer([logger], { mergeWithExisting: false }), ); const target = { alias: "devbox", @@ -590,6 +697,10 @@ describe("ssh tunnel scripts", () => { assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); assert.equal(tunnelKillCount, 1); + + const logText = flattenedLogText(capturedLogs); + assert.include(logText, "httpBaseUrlDiagnostics"); + assert.notInclude(logText, "httpBaseUrl\nhttp://"); }).pipe(Effect.provide(layer), Effect.scoped); }); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 38e09c18b4d..119b4f417ad 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -5,6 +5,8 @@ import type { import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -243,7 +245,24 @@ function applyScriptPlaceholders( return result; } -export function describeReadinessCause(cause: unknown): unknown { +const SSH_FAILURE_DIAGNOSTIC_DEPTH = 6; + +function describeReadinessCauseAtDepth(cause: unknown, depth: number): unknown { + if (depth >= SSH_FAILURE_DIAGNOSTIC_DEPTH) return { truncated: true }; + const describeNested = (nested: unknown) => describeReadinessCauseAtDepth(nested, depth + 1); + if (Cause.isCause(cause)) { + return { + reasons: cause.reasons.map((reason) => { + if (Cause.isFailReason(reason)) { + return { _tag: reason._tag, error: describeNested(reason.error) }; + } + if (Cause.isDieReason(reason)) { + return { _tag: reason._tag, defect: describeNested(reason.defect) }; + } + return { _tag: reason._tag, fiberId: reason.fiberId }; + }), + }; + } if (isSshReadinessError(cause)) { const { cause: nestedCause, ...attributes } = cause as SshReadinessError & { readonly cause?: unknown; @@ -251,13 +270,15 @@ export function describeReadinessCause(cause: unknown): unknown { return { ...attributes, message: cause.message, - ...(nestedCause === undefined ? {} : { cause: describeReadinessCause(nestedCause) }), + ...(nestedCause === undefined ? {} : { cause: describeNested(nestedCause) }), }; } if (cause instanceof Error) { + const tagged = cause as Error & { readonly _tag?: unknown; readonly reason?: unknown }; return { - name: cause.name, - ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), + ...(typeof tagged._tag === "string" ? { _tag: tagged._tag } : { name: cause.name }), + ...(tagged.reason === undefined ? {} : { reason: describeNested(tagged.reason) }), + ...(cause.cause === undefined ? {} : { cause: describeNested(cause.cause) }), }; } if (typeof cause !== "object" || cause === null) { @@ -267,17 +288,23 @@ export function describeReadinessCause(cause: unknown): unknown { const record = cause as Readonly>; return { ...(typeof record._tag === "string" ? { _tag: record._tag } : {}), - ...(record.reason === undefined ? {} : { reason: describeReadinessCause(record.reason) }), - ...(record.cause === undefined ? {} : { cause: describeReadinessCause(record.cause) }), + ...(record.reason === undefined ? {} : { reason: describeNested(record.reason) }), + ...(record.cause === undefined ? {} : { cause: describeNested(record.cause) }), }; } -function readinessUrlDiagnostics(url: URL, urlLength: number) { +export function describeReadinessCause(cause: unknown): unknown { + return describeReadinessCauseAtDepth(cause, 0); +} + +function readinessUrlDiagnostics(input: string) { + const url = new URL(input); + const diagnostics = getUrlDiagnostics(input); return { - protocol: url.protocol, - hostname: url.hostname, + protocol: diagnostics.protocol ?? url.protocol, + hostname: diagnostics.hostname ?? url.hostname, ...(url.port === "" ? {} : { port: url.port }), - urlLength, + urlLength: diagnostics.inputLength, pathnameLength: utf8ByteLength(url.pathname), hasQuery: url.search !== "", hasFragment: url.hash !== "", @@ -900,8 +927,8 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio const baseUrl = new URL(input.baseUrl); const request = new URL(input.path ?? "/", baseUrl); const requestUrl = request.toString(); - const base = readinessUrlDiagnostics(baseUrl, utf8ByteLength(input.baseUrl)); - const requestDiagnostics = readinessUrlDiagnostics(request, utf8ByteLength(requestUrl)); + const base = readinessUrlDiagnostics(input.baseUrl); + const requestDiagnostics = readinessUrlDiagnostics(requestUrl); const client = yield* HttpClient.HttpClient; const lastProbeFailure = yield* Ref.make(null); let attempt = 0; @@ -1088,6 +1115,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: const sshCommand = yield* resolveSshCommand; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const scope = yield* Scope.Scope; + const httpBaseUrlDiagnostics = getUrlDiagnostics(input.httpBaseUrl); yield* Effect.logDebug("ssh.tunnel.spawn.start", { ...sshTargetLogFields(input.resolvedTarget), command: sshCommand, @@ -1095,7 +1123,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: localPort: input.localPort, remotePort: input.remotePort, remoteServerKind: input.remoteServerKind, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }); const child = yield* spawner .spawn( @@ -1128,7 +1156,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }); const tunnelEntry: SshTunnelEntry = { key: input.key, @@ -1171,7 +1199,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, exitCode, stderrBytes: utf8ByteLength(stderr), }).pipe(Effect.andThen(Effect.fail(error))); @@ -1192,7 +1220,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: pid: child.pid, localPort: input.localPort, remotePort: input.remotePort, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }), ), Effect.tapError((cause) => @@ -1220,19 +1248,19 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: processRunning, ...(Exit.isSuccess(processRunningExit) ? {} - : { processRunningError: processRunningExit.cause }), + : { processRunningFailure: describeReadinessCause(processRunningExit.cause) }), localPort: input.localPort, localPortListening: localPortAvailable === null ? null : !localPortAvailable, remotePort: input.remotePort, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, ...(Exit.isSuccess(localPortAvailableExit) ? {} - : { localPortProbeError: localPortAvailableExit.cause }), + : { localPortProbeFailure: describeReadinessCause(localPortAvailableExit.cause) }), ...(remoteLogTail === null ? {} : { remoteLogTailBytes: utf8ByteLength(remoteLogTail) }), ...(Exit.isSuccess(remoteLogTailExit) ? {} - : { remoteLogTailError: remoteLogTailExit.cause }), - cause, + : { remoteLogTailFailure: describeReadinessCause(remoteLogTailExit.cause) }), + failure: describeReadinessCause(cause), }); }), ), @@ -1370,7 +1398,7 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* ...sshTargetLogFields(input.target), key: input.key, promptCount: input.promptCount, - cause: input.error, + failure: describeReadinessCause(input.error), }); const promptService = yield* SshAuth.SshPasswordPrompt; if (!promptService.isAvailable) { @@ -1569,7 +1597,7 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* key, localPort: entry.localPort, remotePort: entry.remotePort, - cause: readinessExit.cause, + failure: describeReadinessCause(readinessExit.cause), }); yield* closeTunnelEntry(entry); yield* cancelPendingTunnelEntry(key, resolvedTarget); @@ -1597,7 +1625,7 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* Effect.logWarning("ssh.environment.tunnel.create.failed", { ...sshTargetLogFields(resolvedTarget), key, - cause, + failure: describeReadinessCause(cause), }), ), Effect.onExit((exit) => From 8abe5f15a6852ce7883e4fa97434572e2f85073c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:30:26 -0700 Subject: [PATCH 15/26] [codex] Structure loopback port errors (#3358) Co-authored-by: codex --- packages/shared/src/Net.test.ts | 123 +++++++++++++++++++++++++-- packages/shared/src/Net.ts | 143 ++++++++++++++++++++++++++++---- 2 files changed, 239 insertions(+), 27 deletions(-) diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 7c3325fce2e..d1e95bb2fa5 100644 --- a/packages/shared/src/Net.test.ts +++ b/packages/shared/src/Net.test.ts @@ -2,9 +2,16 @@ import * as NodeNet from "node:net"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as NetService from "./Net.ts"; +const isLoopbackPortListenError = Schema.is(NetService.LoopbackPortListenError); +const isLoopbackPortAddressUnavailableError = Schema.is( + NetService.LoopbackPortAddressUnavailableError, +); +const isLoopbackPortReleaseError = Schema.is(NetService.LoopbackPortReleaseError); + const closeServer = (server: NodeNet.Server) => Effect.sync(() => { try { @@ -19,19 +26,19 @@ const getPort = (server: NodeNet.Server): number => { return typeof address === "object" && address !== null ? address.port : 0; }; -const openServer = (host?: string): Effect.Effect => - Effect.callback((resume) => { +const openServer = (host?: string): Effect.Effect => + Effect.callback((resume) => { const server = NodeNet.createServer(); let settled = false; - const settle = (effect: Effect.Effect) => { + const settle = (effect: Effect.Effect) => { if (settled) return; settled = true; resume(effect); }; server.once("error", (cause) => { - settle(Effect.fail(new NetService.NetError({ host: host ?? "localhost", cause }))); + settle(Effect.fail(cause)); }); if (host) { @@ -45,11 +52,6 @@ const openServer = (host?: string): Effect.Effect { describe("Net helpers", () => { - it("preserves the loopback reservation error message", () => { - const error = new NetService.NetError({ host: "127.0.0.1" }); - assert.equal(error.message, "Failed to reserve loopback port"); - }); - it.effect("reserveLoopbackPort returns a positive loopback port", () => Effect.gen(function* () { const net = yield* NetService.NetService; @@ -59,6 +61,109 @@ it.layer(NetService.layer)("NetService", (it) => { }), ); + it.effect("retains the host and listen cause when reservation fails", () => + Effect.gen(function* () { + const net = yield* NetService.NetService; + const error = yield* net.reserveLoopbackPort("256.256.256.256").pipe(Effect.flip); + + assert(isLoopbackPortListenError(error)); + assert.equal(error.host, "256.256.256.256"); + assert.match(error.message, /256\.256\.256\.256/u); + assert.equal((error.cause as NodeJS.ErrnoException).code, "ENOTFOUND"); + }), + ); + + it.effect("classifies server errors during close as release failures", () => { + const probe = NodeNet.createServer(); + const cause = new Error("close failed"); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: 43123, + })) as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = (() => { + probe.emit("error", cause); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + return Effect.gen(function* () { + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortReleaseError(error)); + assert.equal(error.host, "127.0.0.1"); + assert.equal(error.port, 43123); + assert.strictEqual(error.cause, cause); + }); + }); + + it.effect("preserves address context when an unusable reservation errors during close", () => + Effect.gen(function* () { + for (const invalidPort of [null, 43.5, 65_536]) { + const probe = NodeNet.createServer(); + const cause = new Error("close failed"); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: invalidPort, + })) as unknown as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = (() => { + probe.emit("error", cause); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortAddressUnavailableError(error)); + assert.equal(error.host, "127.0.0.1"); + assert.equal(error.address, "127.0.0.1"); + assert.equal(error.family, "IPv4"); + assert.equal(error.port, invalidPort); + assert.strictEqual(error.cause, cause); + } + }), + ); + + it.effect("rejects missing and non-finite ports returned by the server", () => + Effect.gen(function* () { + for (const invalidPort of [undefined, Number.NaN]) { + const probe = NodeNet.createServer(); + probe.unref = (() => probe) as typeof probe.unref; + probe.address = (() => ({ + address: "127.0.0.1", + family: "IPv4", + port: invalidPort, + })) as unknown as typeof probe.address; + probe.listen = ((_port: number, _host: string, listeningListener: () => void) => { + listeningListener(); + return probe; + }) as typeof probe.listen; + probe.close = ((callback?: (cause?: Error) => void) => { + callback?.(); + return probe; + }) as typeof probe.close; + const net = NetService.make({ createServer: () => probe }); + + const error = yield* net.reserveLoopbackPort().pipe(Effect.flip); + + assert(isLoopbackPortAddressUnavailableError(error)); + assert.equal(error.port, null); + assert.equal("cause" in error, false); + } + }), + ); + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => Effect.acquireUseRelease( openServer("127.0.0.1"), diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts index d5d4cfafac0..50bb65e4580 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -6,15 +6,53 @@ import * as Layer from "effect/Layer"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; -export class NetError extends Schema.TaggedErrorClass()("NetError", { - host: Schema.String, - cause: Schema.optional(Schema.Defect()), -}) { +export class LoopbackPortListenError extends Schema.TaggedErrorClass()( + "LoopbackPortListenError", + { + host: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to listen for an ephemeral loopback port on ${this.host}.`; + } +} + +export class LoopbackPortAddressUnavailableError extends Schema.TaggedErrorClass()( + "LoopbackPortAddressUnavailableError", + { + host: Schema.String, + address: Schema.NullOr(Schema.String), + family: Schema.NullOr(Schema.String), + port: Schema.NullOr(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { override get message(): string { - return "Failed to reserve loopback port"; + return `Failed to read a usable ephemeral loopback port for ${this.host} (address ${this.address ?? "unavailable"}, family ${this.family ?? "unavailable"}, port ${this.port ?? "unavailable"}).`; } } +export class LoopbackPortReleaseError extends Schema.TaggedErrorClass()( + "LoopbackPortReleaseError", + { + host: Schema.String, + port: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to release ephemeral loopback port ${this.port} on ${this.host}.`; + } +} + +export const NetError = Schema.Union([ + LoopbackPortListenError, + LoopbackPortAddressUnavailableError, + LoopbackPortReleaseError, +]); +export type NetError = typeof NetError.Type; + const isErrnoExceptionWithCode = ( cause: unknown, ): cause is { @@ -24,6 +62,9 @@ const isErrnoExceptionWithCode = ( Predicate.hasProperty(cause, "code") && Predicate.isString(cause.code); +const isUsablePort = (port: number | null): port is number => + port !== null && Number.isInteger(port) && port > 0 && port <= 65_535; + const closeServer = (server: NodeNet.Server) => { try { server.close(); @@ -60,7 +101,13 @@ export class NetService extends Context.Service< } >()("@t3tools/shared/Net/NetService") {} -export const make = () => { +export const make = ( + options: { + readonly createServer?: () => NodeNet.Server; + } = {}, +) => { + const createServer = options.createServer ?? NodeNet.createServer; + /** * Returns true when a TCP server can bind to {host, port}. * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail @@ -68,7 +115,7 @@ export const make = () => { */ const canListenOnHost = (port: number, host: string): Effect.Effect => Effect.callback((resume) => { - const server = NodeNet.createServer(); + const server = createServer(); let settled = false; const settle = (value: boolean) => { @@ -153,8 +200,15 @@ export const make = () => { */ const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => Effect.callback((resume) => { - const probe = NodeNet.createServer(); + const probe = createServer(); let settled = false; + let addressDetails: + | { + readonly address: string | null; + readonly family: string | null; + readonly port: number | null; + } + | undefined; const settle = (effect: Effect.Effect) => { if (settled) return; @@ -163,20 +217,73 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ host, cause }))); + settle( + Effect.fail( + addressDetails === undefined + ? new LoopbackPortListenError({ host, cause }) + : isUsablePort(addressDetails.port) + ? new LoopbackPortReleaseError({ host, port: addressDetails.port, cause }) + : new LoopbackPortAddressUnavailableError({ + host, + ...addressDetails, + cause, + }), + ), + ); }); - probe.listen(0, host, () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { + try { + probe.listen(0, host, () => { + const address = probe.address(); + const resolvedAddressDetails = + typeof address === "object" && address !== null + ? { + address: address.address, + family: address.family, + port: + typeof address.port === "number" && Number.isFinite(address.port) + ? address.port + : null, + } + : { + address, + family: null, + port: null, + }; + addressDetails = resolvedAddressDetails; + + probe.close((cause) => { + const port = resolvedAddressDetails.port; + if (!isUsablePort(port)) { + settle( + Effect.fail( + new LoopbackPortAddressUnavailableError({ + host, + ...resolvedAddressDetails, + ...(cause === undefined ? {} : { cause }), + }), + ), + ); + return; + } + if (cause) { + settle( + Effect.fail( + new LoopbackPortReleaseError({ + host, + port, + cause, + }), + ), + ); + return; + } settle(Effect.succeed(port)); - return; - } - settle(Effect.fail(new NetError({ host }))); + }); }); - }); + } catch (cause) { + settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); + } return Effect.sync(() => { closeServer(probe); From 0d94e227e601248c760ce3dafd848691e6d628ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:14:14 -0700 Subject: [PATCH 16/26] [codex] Preserve SSH exit diagnostics Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 2 - packages/ssh/src/auth.test.ts | 6 -- packages/ssh/src/auth.ts | 16 ++++ packages/ssh/src/command.test.ts | 4 +- packages/ssh/src/command.ts | 11 +-- packages/ssh/src/errors.ts | 88 ++++++++++--------- packages/ssh/src/tunnel.test.ts | 74 ++++++++++++++++ packages/ssh/src/tunnel.ts | 21 ++--- 8 files changed, 148 insertions(+), 74 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index e776b49bb3d..fd7b0ac29f7 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -98,8 +98,6 @@ describe("SSH environment IPC", () => { const structured = new SshCommandSpawnError({ command: "ssh", argumentCount: 0, - exitCode: null, - stderrBytes: 0, target: "devbox", cause, }); diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index e0eae6de156..f5dbfc5ac36 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -36,18 +36,12 @@ describe("ssh auth", () => { const commandFailure = new SshErrors.SshCommandSpawnError({ command: "ssh", argumentCount: 1, - exitCode: null, - stderrBytes: 0, target: "devbox", cause: authFailure, }); assert.equal(isSshAuthFailure(commandFailure), true); const helperFailure = new SshErrors.SshAuthenticationHelperError({ - command: "ssh", - argumentCount: 1, - exitCode: null, - stderrBytes: 0, target: "devbox", cause: authFailure, }); diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index b289ffc0053..c9d9c147fa0 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -217,6 +217,19 @@ function isSshAuthFailureMessage(message: string): boolean { ); } +export function classifySshProcessExit(input: { + readonly stdout: string; + readonly stderr: string; +}): SshErrors.SshProcessExitReason { + return isSshAuthFailureMessage(`${input.stderr}\n${input.stdout}`) + ? "authentication-failed" + : "process-exited"; +} + +const isSshAuthFailureExit = Schema.is( + Schema.Union([SshErrors.SshCommandExitError, SshErrors.SshTunnelExitError]), +); + const isSshAuthFailureCauseWrapper = Schema.is( Schema.Union([ SshErrors.SshCommandSpawnError, @@ -232,6 +245,9 @@ export function isSshAuthFailure(error: unknown): boolean { while (!visited.has(current)) { visited.add(current); + if (isSshAuthFailureExit(current)) { + return current.reason === "authentication-failed"; + } const message = current instanceof Error ? current.message : String(current); if (isSshAuthFailureMessage(message)) { return true; diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e25b2019709..fdc2fe60208 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -184,8 +184,6 @@ describe("ssh command", () => { const spawnError = new SshCommandSpawnError({ command: "ssh", argumentCount: 0, - exitCode: null, - stderrBytes: 0, target: "devbox", cause: spawnCause, }); @@ -254,7 +252,7 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.include(result.failure.message, "SSH command timed out after 1ms."); + assert.equal(result.failure.message, "SSH command timed out after 1ms for devbox."); } }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 27b3b5164dd..2e5559ad283 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -178,10 +178,6 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.mapError( (cause) => new SshAuthenticationHelperError({ - command: "ssh", - argumentCount: 0, - exitCode: null, - stderrBytes: 0, target: hostSpec, cause, }), @@ -222,8 +218,6 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func new SshCommandSpawnError({ command: sshCommand, argumentCount: args.length, - exitCode: null, - stderrBytes: 0, target: hostSpec, cause, }), @@ -243,8 +237,6 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func new SshCommandExecutionError({ command: sshCommand, argumentCount: args.length, - exitCode: null, - stderrBytes: 0, target: hostSpec, cause, }), @@ -267,6 +259,7 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func stdoutBytes: utf8ByteLength(stdout), stderrBytes: utf8ByteLength(stderr), target: hostSpec, + reason: SshAuth.classifySshProcessExit({ stdout, stderr }), }); } @@ -312,8 +305,6 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( (input.preHostArgs?.length ?? 0) + 1 + (input.remoteCommandArgs?.length ?? 0), - exitCode: null, - stderrBytes: 0, target: target.alias || target.hostname, timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index 1ddc635dd9f..3998d970408 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -41,117 +41,125 @@ export const SshInvalidTargetError = Schema.Union([ ]); export type SshInvalidTargetError = typeof SshInvalidTargetError.Type; -const SshCommandErrorFields = { +const SshCommandContextFields = { command: Schema.String, argumentCount: Schema.Number, - exitCode: Schema.NullOr(Schema.Number), - stderrBytes: Schema.Number, - stdoutBytes: Schema.optional(Schema.Number), - target: Schema.optional(Schema.String), - timeoutMs: Schema.optional(Schema.Number), + target: Schema.String, }; -const SshCommandFailureFields = { - ...SshCommandErrorFields, +const SshCommandCauseFields = { + ...SshCommandContextFields, cause: Schema.Defect(), }; +export const SshProcessExitReason = Schema.Literals(["authentication-failed", "process-exited"]); +export type SshProcessExitReason = typeof SshProcessExitReason.Type; + export class SshAuthenticationHelperError extends Schema.TaggedErrorClass()( "SshAuthenticationHelperError", { - ...SshCommandFailureFields, + target: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to prepare SSH authentication helpers."; + return `Failed to prepare SSH authentication helpers for ${this.target}.`; } } export class SshCommandSpawnError extends Schema.TaggedErrorClass()( "SshCommandSpawnError", { - ...SshCommandFailureFields, + ...SshCommandCauseFields, }, ) { override get message(): string { - return `Failed to spawn SSH command${targetSuffix(this.target)}.`; + return `Failed to spawn SSH command for ${this.target}.`; } } export class SshCommandExecutionError extends Schema.TaggedErrorClass()( "SshCommandExecutionError", { - ...SshCommandFailureFields, + ...SshCommandCauseFields, }, ) { override get message(): string { - return `Failed to run SSH command${targetSuffix(this.target)}.`; + return `Failed to run SSH command for ${this.target}.`; } } export class SshCommandExitError extends Schema.TaggedErrorClass()( "SshCommandExitError", { - ...SshCommandErrorFields, + ...SshCommandContextFields, + exitCode: Schema.Number, + stdoutBytes: Schema.Number, + stderrBytes: Schema.Number, + reason: SshProcessExitReason, }, ) { override get message(): string { - return `SSH command failed${targetSuffix(this.target)}${exitCodeSuffix(this.exitCode)}.`; + return `SSH command failed for ${this.target} (exit ${this.exitCode}).`; } } export class SshCommandTimeoutError extends Schema.TaggedErrorClass()( "SshCommandTimeoutError", { - ...SshCommandErrorFields, + ...SshCommandContextFields, + timeoutMs: Schema.Number, }, ) { override get message(): string { - return `SSH command timed out after ${this.timeoutMs ?? 0}ms.`; + return `SSH command timed out after ${this.timeoutMs}ms for ${this.target}.`; } } export class SshCommandCancelledError extends Schema.TaggedErrorClass()( "SshCommandCancelledError", { - ...SshCommandErrorFields, + target: Schema.String, }, ) { override get message(): string { - return `SSH environment connection was cancelled${targetSuffix(this.target)}.`; + return `SSH environment connection was cancelled for ${this.target}.`; } } export class SshTunnelSpawnError extends Schema.TaggedErrorClass()( "SshTunnelSpawnError", { - ...SshCommandFailureFields, + ...SshCommandCauseFields, }, ) { override get message(): string { - return `Failed to spawn SSH tunnel${targetSuffix(this.target)}.`; + return `Failed to spawn SSH tunnel for ${this.target}.`; } } export class SshTunnelMonitorError extends Schema.TaggedErrorClass()( "SshTunnelMonitorError", { - ...SshCommandFailureFields, + ...SshCommandCauseFields, }, ) { override get message(): string { - return `Failed to monitor SSH tunnel${targetSuffix(this.target)}.`; + return `Failed to monitor SSH tunnel for ${this.target}.`; } } export class SshTunnelExitError extends Schema.TaggedErrorClass()( "SshTunnelExitError", { - ...SshCommandErrorFields, + ...SshCommandContextFields, + exitCode: Schema.Number, + stderrBytes: Schema.Number, + reason: SshProcessExitReason, }, ) { override get message(): string { - return `SSH tunnel exited unexpectedly${targetSuffix(this.target)}${exitCodeSuffix(this.exitCode)}.`; + return `SSH tunnel exited unexpectedly for ${this.target} (exit ${this.exitCode}).`; } } @@ -171,35 +179,38 @@ export type SshCommandError = typeof SshCommandError.Type; export class SshLaunchPortMissingError extends Schema.TaggedErrorClass()( "SshLaunchPortMissingError", { + target: Schema.String, stdoutBytes: Schema.Number, }, ) { override get message(): string { - return "SSH launch did not return a remote port."; + return `SSH launch for ${this.target} did not return a remote port.`; } } export class SshLaunchOutputParseError extends Schema.TaggedErrorClass()( "SshLaunchOutputParseError", { + target: Schema.String, stdoutBytes: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return "SSH launch returned unparseable output."; + return `SSH launch for ${this.target} returned unparseable output.`; } } export class SshLaunchInvalidPortError extends Schema.TaggedErrorClass()( "SshLaunchInvalidPortError", { + target: Schema.String, stdoutBytes: Schema.Number, - remotePort: Schema.optional(Schema.Number), + remotePort: Schema.Number, }, ) { override get message(): string { - return `SSH launch returned an invalid remote port: ${String(this.remotePort)}.`; + return `SSH launch for ${this.target} returned an invalid remote port: ${this.remotePort}.`; } } @@ -213,34 +224,37 @@ export type SshLaunchError = typeof SshLaunchError.Type; export class SshPairingCredentialMissingError extends Schema.TaggedErrorClass()( "SshPairingCredentialMissingError", { + target: Schema.String, stdoutBytes: Schema.Number, }, ) { override get message(): string { - return "SSH pairing did not return a credential."; + return `SSH pairing for ${this.target} did not return a credential.`; } } export class SshPairingOutputParseError extends Schema.TaggedErrorClass()( "SshPairingOutputParseError", { + target: Schema.String, stdoutBytes: Schema.Number, cause: Schema.Defect(), }, ) { override get message(): string { - return "SSH pairing returned unparseable output."; + return `SSH pairing for ${this.target} returned unparseable output.`; } } export class SshPairingInvalidCredentialError extends Schema.TaggedErrorClass()( "SshPairingInvalidCredentialError", { + target: Schema.String, stdoutBytes: Schema.Number, }, ) { override get message(): string { - return "SSH pairing command returned an invalid credential."; + return `SSH pairing for ${this.target} returned an invalid credential.`; } } @@ -459,11 +473,3 @@ export const SshPasswordPromptError = Schema.Union([ SshPasswordPromptRequestError, ]); export type SshPasswordPromptError = typeof SshPasswordPromptError.Type; - -function targetSuffix(target: string | undefined): string { - return target ? ` for ${target}` : ""; -} - -function exitCodeSuffix(exitCode: number | null): string { - return exitCode === null ? "" : ` (exit ${exitCode})`; -} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index d64c9eddd11..1264bd24b96 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -48,6 +48,26 @@ const makeSuccessfulProcess = (stdout: string) => { }); }; +const makeFailedProcess = (input: { readonly stdout?: string; readonly stderr?: string }) => { + const stdout = input.stdout ?? ""; + const stderr = input.stderr ?? ""; + const stdoutStream = stdout === "" ? Stream.empty : Stream.make(new TextEncoder().encode(stdout)); + const stderrStream = stderr === "" ? Stream.empty : Stream.make(new TextEncoder().encode(stderr)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: stdoutStream, + stderr: stderrStream, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); +}; + const makeRunningProcess = (onKill: () => void) => { let finish: ((exitCode: ChildProcessSpawner.ExitCode) => void) | null = null; return ChildProcessSpawner.makeHandle({ @@ -576,6 +596,7 @@ describe("ssh tunnel scripts", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { assert.instanceOf(result.failure, SshPairingOutputParseError); + assert.equal(result.failure.target, "devbox"); assert.equal(result.failure.stdoutBytes, new TextEncoder().encode(output).length); assert.isFalse("stdout" in result.failure); assert.notInclude(result.failure.message, "pairing-secret"); @@ -640,6 +661,59 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.effect("retries authentication after a permission-denied SSH exit", () => { + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const promptAttempts: number[] = []; + let launchAttempts = 0; + const spawner = ChildProcessSpawner.make((command) => { + const args = commandArgs(command); + if (args.includes("sh") && args.includes("--")) { + launchAttempts += 1; + if (launchAttempts === 1) { + return Effect.succeed( + makeFailedProcess({ + stderr: "julius@devbox: Permission denied (publickey,password).\n", + }), + ); + } + return Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')); + } + if (args.includes("-N")) { + return Effect.succeed(makeRunningProcess(() => {})); + } + return Effect.succeed(makeSuccessfulProcess("\n")); + }); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.layer({ + isAvailable: true, + request: (request) => + Effect.sync(() => { + promptAttempts.push(request.attempt); + return "secret"; + }), + }), + SshTunnel.layer(), + ); + + return Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const environment = yield* manager.ensureEnvironment(target); + + assert.equal(environment.remotePort, 3773); + assert.equal(launchAttempts, 2); + assert.deepEqual(promptAttempts, [1]); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.effect("closes the tunnel scope and starts fresh after disconnect", () => { const spawnedCommands: Array> = []; let tunnelKillCount = 0; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 119b4f417ad..7830cf095a0 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -772,6 +772,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo SshCommandError | SshInvalidTargetError | SshLaunchError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { + const destination = target.alias.trim() || target.hostname.trim(); yield* Effect.logInfo("ssh.remoteServer.launch.start", { ...sshTargetLogFields(target), ...sshRunnerLogFields(runner), @@ -786,6 +787,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo }); if (!getLastNonEmptyOutputLine(result.stdout)) { return yield* new SshLaunchPortMissingError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), }); } @@ -793,6 +795,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo Effect.mapError( (cause) => new SshLaunchOutputParseError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), cause, }), @@ -800,6 +803,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo ); if (!Number.isInteger(parsed.remotePort)) { return yield* new SshLaunchInvalidPortError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), remotePort: parsed.remotePort, }); @@ -828,6 +832,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT SshCommandError | SshInvalidTargetError | SshPairingError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { + const destination = target.alias.trim() || target.hostname.trim(); yield* Effect.logDebug("ssh.remoteServer.pairingToken.start", { ...sshTargetLogFields(target), stateKey: remoteStateKey(target), @@ -841,6 +846,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT }); if (!getLastNonEmptyOutputLine(result.stdout)) { return yield* new SshPairingCredentialMissingError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), }); } @@ -848,6 +854,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT Effect.mapError( (cause) => new SshPairingOutputParseError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), cause, }), @@ -855,6 +862,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT ); if (parsed.credential.trim().length === 0) { return yield* new SshPairingInvalidCredentialError({ + target: destination, stdoutBytes: utf8ByteLength(result.stdout), }); } @@ -1087,10 +1095,6 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: Effect.mapError( (cause) => new SshAuthenticationHelperError({ - command: "ssh", - argumentCount: 0, - exitCode: null, - stderrBytes: 0, target: hostSpec, cause, }), @@ -1142,8 +1146,6 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: new SshTunnelSpawnError({ command: sshCommand, argumentCount: args.length, - exitCode: null, - stderrBytes: 0, target: input.resolvedTarget.alias, cause, }), @@ -1178,8 +1180,6 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: new SshTunnelMonitorError({ command: sshCommand, argumentCount: args.length, - exitCode: null, - stderrBytes: 0, target: input.resolvedTarget.alias, cause, }), @@ -1191,6 +1191,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: exitCode, stderrBytes: utf8ByteLength(stderr), target: input.resolvedTarget.alias, + reason: SshAuth.classifySshProcessExit({ stdout: "", stderr }), }); return Effect.logWarning("ssh.tunnel.process.exited", { ...sshTargetLogFields(input.resolvedTarget), @@ -1319,10 +1320,6 @@ export const make = Effect.fn("ssh/tunnel.SshEnvironmentManager.make")(function* yield* Deferred.fail( pending, new SshCommandCancelledError({ - command: "ssh", - argumentCount: 0, - exitCode: null, - stderrBytes: 0, target: target.alias || target.hostname, }), ).pipe(Effect.ignore); From c0f27160bf2b6662892854037444adb25e6df770 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:14:20 -0700 Subject: [PATCH 17/26] [codex] Keep desktop path probes observable Co-authored-by: codex --- .../src/app/DesktopAppIdentity.test.ts | 44 +++++---- apps/desktop/src/app/DesktopAppIdentity.ts | 35 +++----- apps/desktop/src/app/DesktopAssets.test.ts | 89 +++++++++++-------- apps/desktop/src/app/DesktopAssets.ts | 53 +++++------ 4 files changed, 113 insertions(+), 108 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 3c95b266bc1..9a04310dea1 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; @@ -36,6 +37,14 @@ interface ElectronAppCalls { readonly setName: string[]; } +function flattenedLogText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return String(value); + return Object.entries(value) + .flatMap(([key, nested]) => [key, flattenedLogText(nested)]) + .join("\n"); +} + const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), @@ -123,7 +132,7 @@ const withIdentity = ( Layer.provideMerge( FileSystem.layerNoop({ exists: (path) => - input.legacyPathProbeError + path.includes("T3 Code (Alpha)") && input.legacyPathProbeError !== undefined ? Effect.fail(input.legacyPathProbeError) : Effect.succeed( input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), @@ -153,31 +162,32 @@ describe("DesktopAppIdentity", () => { ), ); - it.effect("preserves failures while inspecting the legacy userData path", () => { - const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; - const cause = PlatformError.systemError({ + it.effect("falls back and reports a failed legacy userData path probe", () => { + const probeError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", method: "exists", - description: "permission denied", - pathOrDescriptor: legacyPath, + pathOrDescriptor: "/Users/alice/Library/Application Support/T3 Code (Alpha)", + }); + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); }); return withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; - const error = yield* identity.resolveUserDataPath.pipe(Effect.flip); - - assert.instanceOf(error, DesktopAppIdentity.DesktopUserDataPathResolutionError); - assert.equal(error.legacyPath, legacyPath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to inspect legacy desktop user-data path at "${legacyPath}".`, - ); + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/t3code"); + const logText = flattenedLogText(capturedLogs); + assert.include(logText, "desktop.appIdentity.legacyUserDataProbe.failed"); + assert.include(logText, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + assert.include(logText, "/Users/alice/Library/Application Support/t3code"); + assert.include(logText, "PermissionDenied"); }), - { legacyPathProbeError: cause }, - ); + { legacyPathProbeError: probeError }, + ).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); }); it.effect("configures app identity from the environment commit override", () => { diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 385e694338d..46badba61d4 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -18,22 +18,10 @@ const AppPackageMetadata = Schema.Struct({ }); const decodeAppPackageMetadata = Schema.decodeEffect(Schema.fromJsonString(AppPackageMetadata)); -export class DesktopUserDataPathResolutionError extends Schema.TaggedErrorClass()( - "DesktopUserDataPathResolutionError", - { - legacyPath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to inspect legacy desktop user-data path at "${this.legacyPath}".`; - } -} - export class DesktopAppIdentity extends Context.Service< DesktopAppIdentity, { - readonly resolveUserDataPath: Effect.Effect; + readonly resolveUserDataPath: Effect.Effect; readonly configure: Effect.Effect; } >()("@t3tools/desktop/app/DesktopAppIdentity") {} @@ -95,18 +83,21 @@ export const make = Effect.gen(function* () { environment.appDataDirectory, environment.legacyUserDataDirName, ); + const fallbackPath = environment.path.join( + environment.appDataDirectory, + environment.userDataDirName, + ); const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), + Effect.tapError((cause) => + Effect.logWarning("desktop.appIdentity.legacyUserDataProbe.failed", { + legacyPath, + fallbackPath, + cause, + }), ), + Effect.orElseSucceed(() => false), ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + return legacyPathExists ? legacyPath : fallbackPath; }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); const configure = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopAssets.test.ts b/apps/desktop/src/app/DesktopAssets.test.ts index 2eb55c72057..685e33b6b87 100644 --- a/apps/desktop/src/app/DesktopAssets.test.ts +++ b/apps/desktop/src/app/DesktopAssets.test.ts @@ -3,55 +3,72 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as DesktopAssets from "./DesktopAssets.ts"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; -const environmentLayer = DesktopEnvironment.layer({ +const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", homeDirectory: "/Users/alice", - platform: "darwin", - processArch: "arm64", + platform: "linux", + processArch: "x64", appVersion: "1.2.3", - appPath: "/Applications/T3 Code.app/Contents/Resources/app.asar", + appPath: "/repo", isPackaged: true, - resourcesPath: "/Applications/T3 Code.app/Contents/Resources", + resourcesPath: "/resources", runningUnderArm64Translation: false, -}).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({})))); +} satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; + +function flattenedLogText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value !== "object" || value === null) return String(value); + return Object.entries(value) + .flatMap(([key, nested]) => [key, flattenedLogText(nested)]) + .join("\n"); +} describe("DesktopAssets", () => { - it.effect("preserves the failed asset candidate and filesystem cause", () => - Effect.gen(function* () { - const fileName = "custom.bin"; - const candidatePath = "/repo/apps/desktop/resources/custom.bin"; - const cause = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "exists", - pathOrDescriptor: candidatePath, - description: "private filesystem diagnostic", - }); - const fileSystemLayer = FileSystem.layerNoop({ - exists: (path) => (path === candidatePath ? Effect.fail(cause) : Effect.succeed(false)), - }); - const assetsLayer = DesktopAssets.layer.pipe( - Layer.provide(Layer.merge(fileSystemLayer, environmentLayer)), - ); - const assets = yield* DesktopAssets.DesktopAssets.pipe(Effect.provide(assetsLayer)); + it.effect("continues resource lookup and reports failed existence probes", () => { + const firstCandidate = "/repo/apps/desktop/resources/icon.ico"; + const fallbackCandidate = "/repo/apps/desktop/prod-resources/icon.ico"; + const probeError = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: firstCandidate, + }); + const capturedLogs: Array> = []; + const logger = Logger.make(({ message }) => { + capturedLogs.push(Array.isArray(message) ? message : [message]); + }); + const fileSystemLayer = FileSystem.layerNoop({ + exists: (path) => { + if (path === firstCandidate) return Effect.fail(probeError); + return Effect.succeed(path === fallbackCandidate); + }, + }); + const environmentLayer = DesktopEnvironment.layer(environmentInput).pipe( + Layer.provide(Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({}))), + ); + const assetsLayer = DesktopAssets.layer.pipe( + Layer.provideMerge(fileSystemLayer), + Layer.provideMerge(environmentLayer), + Layer.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); - const error = yield* assets.resolveResourcePath(fileName).pipe(Effect.flip); + return Effect.gen(function* () { + const assets = yield* DesktopAssets.DesktopAssets; + const iconPaths = yield* assets.iconPaths; - assert.instanceOf(error, DesktopAssets.DesktopAssetProbeError); - assert.equal(error.fileName, fileName); - assert.equal(error.candidatePath, candidatePath); - assert.strictEqual(error.cause, cause); - assert.equal( - error.message, - `Failed to probe desktop asset "${fileName}" at ${candidatePath}.`, - ); - assert.notInclude(error.message, "private filesystem diagnostic"); - }), - ); + assert.deepEqual(iconPaths.ico, Option.some(fallbackCandidate)); + const logText = flattenedLogText(capturedLogs); + assert.include(logText, "desktop.assets.resourceProbe.failed"); + assert.include(logText, firstCandidate); + assert.include(logText, "PermissionDenied"); + }).pipe(Effect.provide(assetsLayer)); + }); }); diff --git a/apps/desktop/src/app/DesktopAssets.ts b/apps/desktop/src/app/DesktopAssets.ts index 95585acab74..15c7090f9e8 100644 --- a/apps/desktop/src/app/DesktopAssets.ts +++ b/apps/desktop/src/app/DesktopAssets.ts @@ -3,7 +3,6 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -13,26 +12,11 @@ export interface DesktopIconPaths { readonly png: Option.Option; } -export class DesktopAssetProbeError extends Schema.TaggedErrorClass()( - "DesktopAssetProbeError", - { - fileName: Schema.String, - candidatePath: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to probe desktop asset "${this.fileName}" at ${this.candidatePath}.`; - } -} - export class DesktopAssets extends Context.Service< DesktopAssets, { readonly iconPaths: Effect.Effect; - readonly resolveResourcePath: ( - fileName: string, - ) => Effect.Effect, DesktopAssetProbeError>; + readonly resolveResourcePath: (fileName: string) => Effect.Effect>; } >()("@t3tools/desktop/app/DesktopAssets") {} @@ -40,20 +24,23 @@ const resolveResourcePath = Effect.fn("desktop.assets.resolveResourcePath")(func fileName: string, ): Effect.fn.Return< Option.Option, - DesktopAssetProbeError, + never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const candidates = environment.resolveResourcePathCandidates(fileName); for (const candidate of candidates) { - const exists = yield* fileSystem - .exists(candidate) - .pipe( - Effect.mapError( - (cause) => new DesktopAssetProbeError({ fileName, candidatePath: candidate, cause }), - ), - ); + const exists = yield* fileSystem.exists(candidate).pipe( + Effect.tapError((cause) => + Effect.logWarning("desktop.assets.resourceProbe.failed", { + fileName, + candidatePath: candidate, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); if (exists) { return Option.some(candidate); } @@ -65,7 +52,7 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( ext: keyof DesktopIconPaths, ): Effect.fn.Return< Option.Option, - DesktopAssetProbeError, + never, FileSystem.FileSystem | DesktopEnvironment.DesktopEnvironment > { const fileSystem = yield* FileSystem.FileSystem; @@ -73,14 +60,14 @@ const resolveIconPath = Effect.fn("desktop.assets.resolveIconPath")(function* ( if (environment.isDevelopment && environment.platform === "darwin" && ext === "png") { const developmentDockIconPath = environment.developmentDockIconPath; const developmentDockIconExists = yield* fileSystem.exists(developmentDockIconPath).pipe( - Effect.mapError( - (cause) => - new DesktopAssetProbeError({ - fileName: "icon.png", - candidatePath: developmentDockIconPath, - cause, - }), + Effect.tapError((cause) => + Effect.logWarning("desktop.assets.resourceProbe.failed", { + fileName: "development-dock-icon", + candidatePath: developmentDockIconPath, + cause, + }), ), + Effect.orElseSucceed(() => false), ); if (developmentDockIconExists) { return Option.some(developmentDockIconPath); From 40a32535eb2a0a9c8c62c99c58e157d42d65308e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:16:04 -0700 Subject: [PATCH 18/26] [codex] Correlate tunnel failures without aliases Co-authored-by: codex --- packages/ssh/src/tunnel.test.ts | 46 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 7 ++--- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1264bd24b96..9e2af3d4f4d 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -26,6 +26,7 @@ import { SshReadinessProbeError, SshReadinessProbeTimeoutError, SshReadinessTimeoutError, + SshTunnelSpawnError, } from "./errors.ts"; import * as SshTunnel from "./tunnel.ts"; @@ -714,6 +715,51 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(layer), Effect.scoped); }); + it.effect("uses the hostname to identify tunnel failures when the alias is empty", () => { + const target = { + alias: "", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + const spawnFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "ssh", + }); + const spawner = ChildProcessSpawner.make((command) => { + const args = commandArgs(command); + if (args.includes("sh") && args.includes("--")) { + return Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')); + } + if (args.includes("-N")) { + return Effect.fail(spawnFailure); + } + return Effect.succeed(makeSuccessfulProcess("\n")); + }); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.disabledLayer, + SshTunnel.layer(), + ); + + return Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + const result = yield* Effect.result(manager.ensureEnvironment(target)); + + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshTunnelSpawnError); + assert.equal(result.failure.target, "devbox.example.com"); + assert.strictEqual(result.failure.cause, spawnFailure); + } + }).pipe(Effect.provide(layer), Effect.scoped); + }); + it.effect("closes the tunnel scope and starts fresh after disconnect", () => { const spawnedCommands: Array> = []; let tunnelKillCount = 0; diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 7830cf095a0..7f9ededa83a 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -1084,6 +1084,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: | Scope.Scope > { const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget); + const destination = input.resolvedTarget.alias.trim() || input.resolvedTarget.hostname.trim(); const childEnvironment = yield* SshAuth.buildSshChildEnvironment({ ...(input.authOptions.authSecret === undefined ? {} @@ -1146,7 +1147,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: new SshTunnelSpawnError({ command: sshCommand, argumentCount: args.length, - target: input.resolvedTarget.alias, + target: destination, cause, }), ), @@ -1180,7 +1181,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: new SshTunnelMonitorError({ command: sshCommand, argumentCount: args.length, - target: input.resolvedTarget.alias, + target: destination, cause, }), ), @@ -1190,7 +1191,7 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: argumentCount: args.length, exitCode, stderrBytes: utf8ByteLength(stderr), - target: input.resolvedTarget.alias, + target: destination, reason: SshAuth.classifySshProcessExit({ stdout: "", stderr }), }); return Effect.logWarning("ssh.tunnel.process.exited", { From 14159eaf6809b3ffe9804692b564121ed85f60b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:23:33 -0700 Subject: [PATCH 19/26] [codex] Trim generator refactor tests Co-authored-by: codex --- packages/effect-acp/scripts/generate.test.ts | 125 ------------------ .../scripts/generate.test.ts | 124 ----------------- 2 files changed, 249 deletions(-) diff --git a/packages/effect-acp/scripts/generate.test.ts b/packages/effect-acp/scripts/generate.test.ts index e11cb460ecf..4b62dba059d 100644 --- a/packages/effect-acp/scripts/generate.test.ts +++ b/packages/effect-acp/scripts/generate.test.ts @@ -3,40 +3,14 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as Generator from "./generate.ts"; const isDownloadError = Schema.is(Generator.AcpGeneratorDownloadError); -const isDownloadFileError = Schema.is(Generator.AcpGeneratorDownloadFileError); -const isDocumentDecodeError = Schema.is(Generator.AcpGeneratorDocumentDecodeError); -const isFormatExitError = Schema.is(Generator.AcpGeneratorFormatExitError); -const isSchemaNameParseError = Schema.is(Generator.AcpGeneratorSchemaNameParseError); -const isSchemaValueDeclarationMissingError = Schema.is( - Generator.AcpGeneratorSchemaValueDeclarationMissingError, -); const httpClient = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); -function processHandle(exitCode: number) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - describe("ACP schema generator errors", () => { it.effect("retains safe URL diagnostics, output path, and HTTP cause when a download fails", () => Effect.gen(function* () { @@ -73,103 +47,4 @@ describe("ACP schema generator errors", () => { ); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - - it.effect("retains download context when the response cannot be written", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const outputPath = yield* fs.makeTempDirectoryScoped({ prefix: "acp-generator-test-" }); - const url = "https://example.test/schema.json"; - const error = yield* Generator.downloadFile(url, outputPath).pipe( - Effect.provideService( - HttpClient.HttpClient, - httpClient(new Response("{}", { status: 200 })), - ), - Effect.flip, - ); - - assert(isDownloadFileError(error)); - expect(error).toMatchObject({ - urlInputLength: url.length, - urlProtocol: "https:", - urlHostname: "example.test", - }); - expect(error).not.toHaveProperty("url"); - expect(error.outputPath).toBe(outputPath); - expect(error.stage).toBe("write-file"); - expect(error.cause).toBeDefined(); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), - ); - - it.effect("adds source file context to upstream document decode failures", () => - Effect.gen(function* () { - const schemaPath = "/tmp/upstream-schema.json"; - const schemaError = yield* Generator.decodeUpstreamSchemaDocument( - "not-json", - schemaPath, - ).pipe(Effect.flip); - assert(isDocumentDecodeError(schemaError)); - expect(schemaError.document).toBe("schema"); - expect(schemaError.filePath).toBe(schemaPath); - expect(schemaError.cause).toBeDefined(); - - const metadataPath = "/tmp/upstream-meta.json"; - const metadataError = yield* Generator.decodeMetaDocument("not-json", metadataPath).pipe( - Effect.flip, - ); - assert(isDocumentDecodeError(metadataError)); - expect(metadataError.document).toBe("metadata"); - expect(metadataError.filePath).toBe(metadataPath); - expect(metadataError.cause).toBeDefined(); - }), - ); - - it.effect("reports formatter commands and nonzero exit codes structurally", () => { - let spawned: ChildProcess.StandardCommand | undefined; - const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) { - spawned = command; - } - return Effect.succeed(processHandle(23)); - }); - - return Effect.gen(function* () { - const generatedDir = "/tmp/acp-generated"; - const error = yield* Generator.formatGeneratedFiles(generatedDir).pipe(Effect.flip); - - assert(isFormatExitError(error)); - expect(error.command).toBe("bun"); - expect(error.argumentCount).toBe(2); - expect(error).not.toHaveProperty("args"); - expect(error.generatedDir).toBe(generatedDir); - expect(error.exitCode).toBe(23); - expect(error.message).toContain("23"); - expect(spawned?.command).toBe("bun"); - expect(spawned?.args).toEqual(["oxfmt", generatedDir]); - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - }); - - it.effect("returns malformed generated schema declarations as typed failures", () => - Effect.gen(function* () { - const missingValueError = yield* Generator.collectSchemaEntries( - "export type Session = string;", - ).pipe(Effect.flip); - assert(isSchemaValueDeclarationMissingError(missingValueError)); - expect(missingValueError).toMatchObject({ - lineIndex: 0, - typeDeclarationLength: 29, - nextLinePresent: false, - }); - expect(missingValueError).not.toHaveProperty("typeDeclaration"); - - const nameParseError = yield* Generator.collectSchemaEntries( - "export type @ = string;\nexport const invalid = Schema.String;", - ).pipe(Effect.flip); - assert(isSchemaNameParseError(nameParseError)); - expect(nameParseError).toMatchObject({ - lineIndex: 0, - typeDeclarationLength: 23, - }); - expect(nameParseError).not.toHaveProperty("typeDeclaration"); - }), - ); }); diff --git a/packages/effect-codex-app-server/scripts/generate.test.ts b/packages/effect-codex-app-server/scripts/generate.test.ts index ed9ee4bd194..3282aaf3aed 100644 --- a/packages/effect-codex-app-server/scripts/generate.test.ts +++ b/packages/effect-codex-app-server/scripts/generate.test.ts @@ -1,42 +1,14 @@ import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as Generator from "./generate.ts"; const isGeneratorFetchError = Schema.is(Generator.GeneratorFetchError); -const isGeneratorDirectoryDecodeError = Schema.is(Generator.GeneratorDirectoryDecodeError); -const isGeneratorSchemaDocumentDecodeError = Schema.is( - Generator.GeneratorSchemaDocumentDecodeError, -); -const isGeneratorFormatExitError = Schema.is(Generator.GeneratorFormatExitError); -const isGeneratorSchemaNameParseError = Schema.is(Generator.GeneratorSchemaNameParseError); -const isGeneratorSchemaValueDeclarationMissingError = Schema.is( - Generator.GeneratorSchemaValueDeclarationMissingError, -); const httpClient = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); -function processHandle(exitCode: number) { - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); -} - describe("Codex schema generator errors", () => { it.effect("retains safe URL diagnostics and the HTTP cause when fetching fails", () => Effect.gen(function* () { @@ -68,100 +40,4 @@ describe("Codex schema generator errors", () => { ); }), ); - - it.effect("adds the GitHub directory path to listing decode failures", () => - Effect.gen(function* () { - const error = yield* Generator.fetchDirectoryEntries("schema/json/v2").pipe( - Effect.provideService( - HttpClient.HttpClient, - httpClient(new Response("not-json", { status: 200 })), - ), - Effect.flip, - ); - - assert(isGeneratorDirectoryDecodeError(error)); - expect(error.directoryPath).toBe("schema/json/v2"); - expect(error).toMatchObject({ - urlProtocol: "https:", - urlHostname: "api.github.com", - }); - expect(error.urlInputLength).toBeGreaterThan(0); - expect(error).not.toHaveProperty("url"); - expect(error.cause).toBeDefined(); - expect(error.message).toContain("schema/json/v2"); - }), - ); - - it.effect("adds repository and download context to schema decode failures", () => - Effect.gen(function* () { - const repositoryPath = "codex-rs/app-server-protocol/schema/json/v2/Thread.json"; - const url = "https://raw.example.test/Thread.json"; - const error = yield* Generator.decodeSchemaDocument({ - repositoryPath, - url, - raw: "not-json", - }).pipe(Effect.flip); - - assert(isGeneratorSchemaDocumentDecodeError(error)); - expect(error.repositoryPath).toBe(repositoryPath); - expect(error).toMatchObject({ - urlInputLength: url.length, - urlProtocol: "https:", - urlHostname: "raw.example.test", - }); - expect(error).not.toHaveProperty("url"); - expect(error.cause).toBeDefined(); - expect(error.message).toContain(repositoryPath); - }), - ); - - it.effect("reports formatter commands and nonzero exit codes structurally", () => { - let spawned: ChildProcess.StandardCommand | undefined; - const spawner = ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) { - spawned = command; - } - return Effect.succeed(processHandle(17)); - }); - - return Effect.gen(function* () { - const generatedDir = "/tmp/codex-generated"; - const error = yield* Generator.formatGeneratedFiles(generatedDir).pipe(Effect.flip); - - assert(isGeneratorFormatExitError(error)); - expect(error.command).toBe("vp"); - expect(error.argumentCount).toBe(3); - expect(error).not.toHaveProperty("args"); - expect(error.generatedDir).toBe(generatedDir); - expect(error.exitCode).toBe(17); - expect(error.message).toContain("17"); - expect(spawned?.command).toBe("vp"); - expect(spawned?.args).toEqual(["fmt", generatedDir, "--write"]); - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - }); - - it.effect("returns malformed generated schema declarations as typed failures", () => - Effect.gen(function* () { - const missingValueError = yield* Generator.collectSchemaEntries( - "export type Session = string;", - ).pipe(Effect.flip); - assert(isGeneratorSchemaValueDeclarationMissingError(missingValueError)); - expect(missingValueError).toMatchObject({ - lineIndex: 0, - typeDeclarationLength: 29, - nextLinePresent: false, - }); - expect(missingValueError).not.toHaveProperty("typeDeclaration"); - - const nameParseError = yield* Generator.collectSchemaEntries( - "export type @ = string;\nexport const invalid = Schema.String;", - ).pipe(Effect.flip); - assert(isGeneratorSchemaNameParseError(nameParseError)); - expect(nameParseError).toMatchObject({ - lineIndex: 0, - typeDeclarationLength: 23, - }); - expect(nameParseError).not.toHaveProperty("typeDeclaration"); - }), - ); }); From ceb4fdc310b1e48259113757c25002ebdc794d26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:10:15 -0700 Subject: [PATCH 20/26] Inline environment authorization errors Co-authored-by: codex --- apps/server/src/server.test.ts | 4 ++++ apps/server/src/ws.ts | 9 ++------- packages/contracts/src/auth.ts | 7 +++++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 9feef4e5200..b4182ef4470 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3132,6 +3132,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(rpcError._tag, "EnvironmentAuthorizationError"); if (rpcError._tag === "EnvironmentAuthorizationError") { assert.equal(rpcError.requiredScope, "orchestration:read"); + assert.equal( + rpcError.message, + "The authenticated token is missing required scope: orchestration:read.", + ); } }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7f483f83577..857582dcb60 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -463,25 +463,20 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; - const authorizationError = (requiredScope: AuthEnvironmentScope) => - new EnvironmentAuthorizationError({ - message: `The authenticated token is missing required scope: ${requiredScope}.`, - requiredScope, - }); const authorizeEffect = ( requiredScope: AuthEnvironmentScope, effect: Effect.Effect, ): Effect.Effect => currentSession.scopes.includes(requiredScope) ? effect - : Effect.fail(authorizationError(requiredScope)); + : Effect.fail(new EnvironmentAuthorizationError({ requiredScope })); const authorizeStream = ( requiredScope: AuthEnvironmentScope, stream: Stream.Stream, ): Stream.Stream => currentSession.scopes.includes(requiredScope) ? stream - : Stream.fail(authorizationError(requiredScope)); + : Stream.fail(new EnvironmentAuthorizationError({ requiredScope })); const requiredScopeForMethod = (method: string): AuthEnvironmentScope => { const requiredScope = RPC_REQUIRED_SCOPE.get(method); if (requiredScope === undefined) { diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 04467729abc..590444dfe53 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -300,10 +300,13 @@ export class AuthAccessStreamError extends Schema.TaggedErrorClass()( "EnvironmentAuthorizationError", { - message: Schema.String, requiredScope: AuthEnvironmentScope, }, -) {} +) { + override get message(): string { + return `The authenticated token is missing required scope: ${this.requiredScope}.`; + } +} export const AuthAccessStreamClientUpsertedEvent = Schema.Struct({ version: Schema.Literal(1), From 2b84cc066ba6253f15092e2915f128ddac0b562d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:39:06 -0700 Subject: [PATCH 21/26] Normalize SSH timeout targets Co-authored-by: codex --- packages/ssh/src/command.test.ts | 7 +++++-- packages/ssh/src/command.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index fdc2fe60208..f87bc527d2d 100644 --- a/packages/ssh/src/command.test.ts +++ b/packages/ssh/src/command.test.ts @@ -236,7 +236,7 @@ describe("ssh command", () => { Effect.result( runSshCommand( { - alias: "devbox", + alias: " ", hostname: "devbox.example.com", username: "julius", port: 2222, @@ -252,7 +252,10 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.equal(result.failure.message, "SSH command timed out after 1ms for devbox."); + assert.equal( + result.failure.message, + "SSH command timed out after 1ms for devbox.example.com.", + ); } }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 2e5559ad283..8086b9e907e 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -305,7 +305,7 @@ export const runSshCommand = Effect.fn("ssh/command.runSshCommand")(function* ( (input.preHostArgs?.length ?? 0) + 1 + (input.remoteCommandArgs?.length ?? 0), - target: target.alias || target.hostname, + target: target.alias.trim() || target.hostname.trim(), timeoutMs: input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS, }); }), From 19550841047d33f2b2ab028e79733e90a26bb0d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:58:27 -0700 Subject: [PATCH 22/26] [codex] Structure command resolution failures (#3441) Co-authored-by: codex --- packages/shared/src/shell.test.ts | 83 +++++++++++++++++++++++++++++-- packages/shared/src/shell.ts | 69 ++++++++++++++++++++----- 2 files changed, 135 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e8b2c41cb77..910c94dd6c1 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,12 +2,16 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it as effectIt } from "@effect/vitest"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import { describe, expect, it, vi } from "vite-plus/test"; import { extractPathFromShellOutput, CommandAvailability, type CommandAvailabilityChecker, + CommandResolutionError, isCommandAvailable, listLoginShellCandidates, mergePathEntries, @@ -355,13 +359,84 @@ effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => { }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { - it.effect("fails when PATH is empty", () => + it.effect("reports the unresolved command and platform when PATH is empty", () => Effect.gen(function* () { - const result = yield* resolveCommandPath("definitely-not-installed", { + const error = yield* resolveCommandPath("definitely-not-installed", { env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, - }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.result); + }).pipe(Effect.provideService(HostProcessPlatform, "win32"), Effect.flip); - expect(result._tag).toBe("Failure"); + expect(error).toBeInstanceOf(CommandResolutionError); + expect(error.command).toBe("definitely-not-installed"); + expect(error.platform).toBe("win32"); + expect(error.message).toBe('Could not resolve command "definitely-not-installed" on win32.'); + }), + ); + + it.effect("continues past a failed PATH probe when a later executable exists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-command-resolution-", + }); + const blockedDirectory = path.join(baseDirectory, "blocked"); + const workingDirectory = path.join(baseDirectory, "working"); + const blockedCandidate = path.join(blockedDirectory, "available-command"); + const workingCandidate = path.join(workingDirectory, "available-command"); + yield* fileSystem.makeDirectory(blockedDirectory); + yield* fileSystem.makeDirectory(workingDirectory); + yield* fileSystem.writeFileString(workingCandidate, "#!/bin/sh\nexit 0\n"); + yield* fileSystem.chmod(workingCandidate, 0o755); + + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: blockedCandidate, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (candidate) => + candidate === blockedCandidate ? Effect.fail(cause) : fileSystem.stat(candidate), + }); + + const resolved = yield* resolveCommandPath("available-command", { + env: { PATH: `${blockedDirectory}:${workingDirectory}` }, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + ); + + expect(resolved).toBe(workingCandidate); + }), + ); + + it.effect("preserves filesystem failures after exhausting PATH candidates", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: "/bin/blocked-command", + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: () => Effect.fail(cause), + }); + + const error = yield* resolveCommandPath("blocked-command", { + env: { PATH: "/bin" }, + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + Effect.flip, + ); + + expect(error).toBeInstanceOf(CommandResolutionError); + expect(error.command).toBe("blocked-command"); + expect(error.platform).toBe("linux"); + expect(error.cause).toBe(cause); }), ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d0aaa821680..246473da586 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -3,13 +3,14 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; -import * as Data from "effect/Data"; +import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import { HostProcessEnvironment, HostProcessPlatform } from "./hostProcess.ts"; -import * as Context from "effect/Context"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; @@ -17,6 +18,19 @@ const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; const WINDOWS_PATH_DELIMITER = ";"; const POSIX_PATH_DELIMITER = ":"; const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; +const ProcessPlatform = Schema.Literals([ + "aix", + "android", + "darwin", + "freebsd", + "haiku", + "linux", + "openbsd", + "sunos", + "win32", + "cygwin", + "netbsd", +]); type ExecFileSyncLike = ( file: string, @@ -43,10 +57,18 @@ export type CommandAvailabilityChecker = ( options?: CommandAvailabilityOptions, ) => Effect.Effect; -export class CommandResolutionError extends Data.TaggedError("CommandResolutionError")<{ - readonly command: string; - readonly reason: "not-found"; -}> {} +export class CommandResolutionError extends Schema.TaggedErrorClass()( + "CommandResolutionError", + { + command: Schema.String, + platform: ProcessPlatform, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Could not resolve command ${JSON.stringify(this.command)} on ${this.platform}.`; + } +} const WINDOWS_SHELL_META_CHARS = /([()\][%!^"`<>&|;, *?])/g; @@ -495,10 +517,15 @@ const isExecutableFile = Effect.fn("shell.isExecutableFile")(function* ( filePath: string, platform: NodeJS.Platform, windowsPathExtensions: ReadonlyArray, -): Effect.fn.Return { +): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stat = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + const stat = yield* fileSystem.stat(filePath).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.succeed(null), + ), + ); if (stat === null || stat.type !== "File") return false; if (platform === "win32") { @@ -524,19 +551,31 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat windowsPathExtensions, path.extname, ); + let firstProbeFailure: PlatformError.PlatformError | undefined; + const probeCandidate = (candidatePath: string) => + isExecutableFile(candidatePath, platform, windowsPathExtensions).pipe( + Effect.catch((cause) => { + firstProbeFailure ??= cause; + return Effect.succeed(false); + }), + ); if (command.includes("/") || command.includes("\\")) { for (const candidate of commandCandidates) { - if (yield* isExecutableFile(candidate, platform, windowsPathExtensions)) { + if (yield* probeCandidate(candidate)) { return candidate; } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ + command, + platform, + ...(firstProbeFailure === undefined ? {} : { cause: firstProbeFailure }), + }); } const pathValue = resolvePathEnvironmentVariable(env); if (pathValue.length === 0) { - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ command, platform }); } const pathEntries: string[] = []; for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { @@ -549,12 +588,16 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat for (const pathEntry of pathEntries) { for (const candidate of commandCandidates) { const candidatePath = path.join(pathEntry, candidate); - if (yield* isExecutableFile(candidatePath, platform, windowsPathExtensions)) { + if (yield* probeCandidate(candidatePath)) { return candidatePath; } } } - return yield* new CommandResolutionError({ command, reason: "not-found" }); + return yield* new CommandResolutionError({ + command, + platform, + ...(firstProbeFailure === undefined ? {} : { cause: firstProbeFailure }), + }); }); export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function* ( From 477a96f9f7b6c89635917197faeb44ef80ee4773 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 16:59:21 -0700 Subject: [PATCH 23/26] Clarify SSH IPC error presentation Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 16 ++++++++++++++-- apps/desktop/src/ipc/methods/sshEnvironment.ts | 9 ++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index fd7b0ac29f7..d6cbe5db0fb 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -5,6 +5,7 @@ import { } from "@t3tools/contracts"; import { SshCommandSpawnError, + SshCommandTimeoutError, SshHttpBridgeError, SshPasswordPromptError, } from "@t3tools/ssh/errors"; @@ -22,7 +23,7 @@ import { DesktopSshEnvironmentRequestError, ensureSshEnvironment, fetchSshEnvironmentDescriptor, - toDesktopSshOperationPresentationError, + toDesktopSshIpcPresentationError, } from "./sshEnvironment.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; @@ -102,12 +103,23 @@ describe("SSH environment IPC", () => { cause, }); - const presentation = toDesktopSshOperationPresentationError(structured); + const presentation = toDesktopSshIpcPresentationError(structured); assert.equal(structured.message, "Failed to spawn SSH command for devbox."); assert.equal(presentation.message, cause.message); assert.strictEqual(presentation.cause, structured); }); + it("passes structural SSH errors without process causes through the IPC boundary", () => { + const structured = new SshCommandTimeoutError({ + command: "ssh", + argumentCount: 0, + target: "devbox", + timeoutMs: 30_000, + }); + + assert.strictEqual(toDesktopSshIpcPresentationError(structured), structured); + }); + it.effect("fetches and decodes the remote environment descriptor", () => { const requestUrls: string[] = []; const layer = makeHttpClientLayer((request) => diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index ced1ab739fa..4c3517e761e 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -73,7 +73,10 @@ const isSshCausePresentationError = Schema.is( ]), ); -export function toDesktopSshOperationPresentationError( +// Electron only forwards the message from a rejected ipcRenderer.invoke promise. Keep the typed, +// structural SSH error intact as the cause for main-process diagnostics while preserving the +// actionable process message that the renderer historically presented to the user. +export function toDesktopSshIpcPresentationError( error: DesktopSshEnvironment.DesktopSshEnvironmentOperationError, ): DesktopSshEnvironment.DesktopSshEnvironmentOperationError | Error { if (isSshCausePresentationError(error) && error.cause instanceof Error) { @@ -169,7 +172,7 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ ) : Effect.fail(error), }), - Effect.mapError(toDesktopSshOperationPresentationError), + Effect.mapError(toDesktopSshIpcPresentationError), ); }), }); @@ -182,7 +185,7 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; yield* sshEnvironment .disconnectEnvironment(target) - .pipe(Effect.mapError(toDesktopSshOperationPresentationError)); + .pipe(Effect.mapError(toDesktopSshIpcPresentationError)); }), }); From 2019140832da70851fc56e73d3ddcb1d40bb6b78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 17:04:07 -0700 Subject: [PATCH 24/26] Schema encode SSH request errors Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 7 ++-- .../desktop/src/ipc/methods/sshEnvironment.ts | 36 +++++++++++-------- .../src/authorization/remote.ts | 5 +-- packages/client-runtime/src/rpc/http.ts | 28 +++++---------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index d6cbe5db0fb..54201ce7559 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -33,6 +33,7 @@ const decodeDesktopSshEnvironmentEnsureResult = Schema.decodeUnknownEffect( ); const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); +const isDesktopSshEnvironmentRequestError = Schema.is(DesktopSshEnvironmentRequestError); function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( @@ -167,7 +168,8 @@ describe("SSH environment IPC", () => { assert(Option.isSome(failure)); const error = failure.value; - assert.instanceOf(error, DesktopSshEnvironmentRequestError); + assert.isTrue(isDesktopSshEnvironmentRequestError(error)); + if (!isDesktopSshEnvironmentRequestError(error)) return; assert.equal(error.operation, "fetch-environment-descriptor"); assert.equal(isSshHttpBridgeError(error.cause), false); }).pipe(Effect.provide(layer)); @@ -193,7 +195,8 @@ describe("SSH environment IPC", () => { assert(Option.isSome(failure)); const error = failure.value; - assert.instanceOf(error, DesktopSshEnvironmentRequestError); + assert.isTrue(isDesktopSshEnvironmentRequestError(error)); + if (!isDesktopSshEnvironmentRequestError(error)) return; assert.equal(isSshHttpBridgeError(error.cause), true); assert.equal(requestCount, 0); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 4c3517e761e..4c96178ee19 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -3,7 +3,7 @@ import { fetchRemoteSessionState, issueRemoteWebSocketTicket, isRemoteEnvironmentAuthUndeclaredStatusError, - type RemoteEnvironmentAuthError, + RemoteEnvironmentAuthError, } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { @@ -34,7 +34,6 @@ import { SshTunnelSpawnError, } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -43,13 +42,19 @@ import * as DesktopIpc from "../DesktopIpc.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; -type DesktopSshEnvironmentRequestOperation = - | "fetch-environment-descriptor" - | "bootstrap-bearer-session" - | "fetch-session-state" - | "issue-websocket-ticket"; +const DesktopSshEnvironmentRequestOperation = Schema.Literals([ + "fetch-environment-descriptor", + "bootstrap-bearer-session", + "fetch-session-state", + "issue-websocket-ticket", +]); +type DesktopSshEnvironmentRequestOperation = typeof DesktopSshEnvironmentRequestOperation.Type; -type DesktopSshEnvironmentRequestCause = RemoteEnvironmentAuthError | SshHttpBridgeError; +const DesktopSshEnvironmentRequestCause = Schema.Union([ + RemoteEnvironmentAuthError, + SshHttpBridgeError, +]); +type DesktopSshEnvironmentRequestCause = typeof DesktopSshEnvironmentRequestCause.Type; const desktopSshPasswordPromptCancellationReasons = { DesktopSshPromptCancelledError: "user-cancelled", @@ -108,14 +113,15 @@ function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | n return null; } -export class DesktopSshEnvironmentRequestError extends Data.TaggedError( +export class DesktopSshEnvironmentRequestError extends Schema.TaggedErrorClass()( "DesktopSshEnvironmentRequestError", -)<{ - readonly operation: DesktopSshEnvironmentRequestOperation; - readonly cause: DesktopSshEnvironmentRequestCause; - readonly sshHttpStatus: number | null; -}> { - override get message() { + { + operation: DesktopSshEnvironmentRequestOperation, + cause: DesktopSshEnvironmentRequestCause, + sshHttpStatus: Schema.NullOr(Schema.Number), + }, +) { + override get message(): string { const prefix = this.sshHttpStatus === null ? "" : `[ssh_http:${this.sshHttpStatus}] `; return `${prefix}SSH remote API request failed during ${this.operation}.`; } diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 4cfa6877ec7..0d2afb17134 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -11,7 +11,7 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient, - type RemoteEnvironmentRequestError, + RemoteEnvironmentRequestError, } from "../rpc/http.ts"; export { @@ -21,7 +21,8 @@ export { RemoteEnvironmentAuthTimeoutError, RemoteEnvironmentAuthUndeclaredStatusError, } from "../rpc/http.ts"; -export type RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; +export const RemoteEnvironmentAuthError = RemoteEnvironmentRequestError; +export type RemoteEnvironmentAuthError = typeof RemoteEnvironmentAuthError.Type; const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index 4b0514df747..e09eaefb71e 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -1,12 +1,4 @@ -import { - EnvironmentHttpApi, - EnvironmentHttpCommonError, - type EnvironmentAuthInvalidError, - type EnvironmentInternalError, - type EnvironmentOperationForbiddenError, - type EnvironmentRequestInvalidError, - type EnvironmentScopeRequiredError, -} from "@t3tools/contracts"; +import { EnvironmentHttpApi, EnvironmentHttpCommonError } from "@t3tools/contracts"; import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability"; import { getUrlDiagnostics } from "@t3tools/shared/urlDiagnostics"; import * as Duration from "effect/Duration"; @@ -133,16 +125,14 @@ export class RemoteEnvironmentAuthTimeoutError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 17:14:18 -0700 Subject: [PATCH 25/26] Structure relay install transport errors Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 6 ++-- .../desktop/src/ipc/methods/sshEnvironment.ts | 29 ++++++++++++------- apps/server/src/ws.ts | 2 +- packages/contracts/src/relayClient.test.ts | 29 +++++++++++++++++++ packages/contracts/src/relayClient.ts | 24 +++++++++++++-- 5 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 packages/contracts/src/relayClient.test.ts diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 54201ce7559..ef4938db86e 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -7,7 +7,7 @@ import { SshCommandSpawnError, SshCommandTimeoutError, SshHttpBridgeError, - SshPasswordPromptError, + SshPasswordPromptWindowClosedError, } from "@t3tools/ssh/errors"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -62,8 +62,8 @@ describe("SSH environment IPC", () => { requestId: "prompt-1", destination: "developer@devbox.example.test", }); - const cause = new SshPasswordPromptError({ - message: promptCause.message, + const cause = new SshPasswordPromptWindowClosedError({ + destination: promptCause.destination, cause: promptCause, }); const layer = Layer.succeed( diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 4c96178ee19..12aeb8e7cdb 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -30,6 +30,7 @@ import { SshCommandExecutionError, SshCommandSpawnError, SshHttpBridgeError, + type SshPasswordPromptError, SshTunnelMonitorError, SshTunnelSpawnError, } from "@t3tools/ssh/errors"; @@ -63,6 +64,19 @@ const desktopSshPasswordPromptCancellationReasons = { DesktopSshPromptTimedOutError: "timed-out", } as const; +function handleDesktopSshPasswordPromptCancellation(error: SshPasswordPromptError) { + return DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) + ? Effect.succeed( + new DesktopSshPasswordPromptCancellationError({ + reason: desktopSshPasswordPromptCancellationReasons[error.cause._tag], + requestId: error.cause.requestId, + destination: error.cause.destination, + cause: error, + }), + ) + : Effect.fail(error); +} + const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); const isEnvironmentInternalError = Schema.is(EnvironmentInternalError); const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbiddenError); @@ -166,17 +180,10 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; return yield* sshEnvironment.ensureEnvironment(target, options).pipe( Effect.catchTags({ - SshPasswordPromptError: (error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed( - new DesktopSshPasswordPromptCancellationError({ - reason: desktopSshPasswordPromptCancellationReasons[error.cause._tag], - requestId: error.cause.requestId, - destination: error.cause.destination, - cause: error, - }), - ) - : Effect.fail(error), + SshPasswordPromptCancelledError: handleDesktopSshPasswordPromptCancellation, + SshPasswordPromptTimedOutError: handleDesktopSshPasswordPromptCancellation, + SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation, + SshPasswordPromptServiceStoppedError: handleDesktopSshPasswordPromptCancellation, }), Effect.mapError(toDesktopSshIpcPresentationError), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 857582dcb60..119d74c6755 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1313,7 +1313,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => (error) => new RelayClientInstallFailedError({ reason: relayClientInstallFailureReason(error), - message: error.message, + cause: error, }), ), Effect.flatMap((status) => diff --git a/packages/contracts/src/relayClient.test.ts b/packages/contracts/src/relayClient.test.ts new file mode 100644 index 00000000000..799a1c4668f --- /dev/null +++ b/packages/contracts/src/relayClient.test.ts @@ -0,0 +1,29 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { RelayClientInstallFailedError } from "./relayClient.ts"; + +const encodeRelayClientInstallFailedError = Schema.encodeSync(RelayClientInstallFailedError); +const decodeRelayClientInstallFailedError = Schema.decodeUnknownSync(RelayClientInstallFailedError); + +describe("RelayClientInstallFailedError", () => { + it("retains its internal cause without serializing it", () => { + const cause = new Error("private download failure"); + const error = new RelayClientInstallFailedError({ + reason: "download_failed", + cause, + }); + + const encoded = encodeRelayClientInstallFailedError(error); + const decoded = decodeRelayClientInstallFailedError(encoded); + + expect(error.cause).toBe(cause); + expect(error.message).toBe("Relay client installation failed (download_failed)."); + expect(encoded).toEqual({ + _tag: "RelayClientInstallFailedError", + reason: "download_failed", + }); + expect(decoded.cause).toBeUndefined(); + expect(decoded.message).toBe("Relay client installation failed (download_failed)."); + }); +}); diff --git a/packages/contracts/src/relayClient.ts b/packages/contracts/src/relayClient.ts index e78078d1eed..14f5f2d33f0 100644 --- a/packages/contracts/src/relayClient.ts +++ b/packages/contracts/src/relayClient.ts @@ -58,6 +58,26 @@ export class RelayClientInstallFailedError extends Schema.TaggedErrorClass Date: Sat, 20 Jun 2026 20:12:14 -0700 Subject: [PATCH 26/26] fix(desktop): preserve structural SSH IPC errors Co-authored-by: codex --- .../src/ipc/methods/sshEnvironment.test.ts | 48 ++++++++++++------- .../desktop/src/ipc/methods/sshEnvironment.ts | 37 ++------------ 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index ef4938db86e..a9cba3b1498 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -5,7 +5,6 @@ import { } from "@t3tools/contracts"; import { SshCommandSpawnError, - SshCommandTimeoutError, SshHttpBridgeError, SshPasswordPromptWindowClosedError, } from "@t3tools/ssh/errors"; @@ -21,9 +20,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { DesktopSshEnvironmentRequestError, + disconnectSshEnvironment, ensureSshEnvironment, fetchSshEnvironmentDescriptor, - toDesktopSshIpcPresentationError, } from "./sshEnvironment.ts"; import * as DesktopSshEnvironment from "../../ssh/DesktopSshEnvironment.ts"; import * as DesktopSshPasswordPrompts from "../../ssh/DesktopSshPasswordPrompts.ts"; @@ -95,7 +94,7 @@ describe("SSH environment IPC", () => { }).pipe(Effect.provide(layer)); }); - it("presents legacy process causes without weakening structured errors", () => { + it.effect("preserves structural SSH errors and exact causes at the IPC boundary", () => { const cause = new Error("ssh executable was not found"); const structured = new SshCommandSpawnError({ command: "ssh", @@ -103,22 +102,35 @@ describe("SSH environment IPC", () => { target: "devbox", cause, }); + const target = { + alias: "devbox", + hostname: "devbox.example.test", + username: "developer", + port: 22, + }; + const layer = Layer.succeed( + DesktopSshEnvironment.DesktopSshEnvironment, + DesktopSshEnvironment.DesktopSshEnvironment.of({ + discoverHosts: () => Effect.die("unexpected host discovery"), + ensureEnvironment: () => Effect.fail(structured), + disconnectEnvironment: () => Effect.fail(structured), + }), + ); - const presentation = toDesktopSshIpcPresentationError(structured); - assert.equal(structured.message, "Failed to spawn SSH command for devbox."); - assert.equal(presentation.message, cause.message); - assert.strictEqual(presentation.cause, structured); - }); - - it("passes structural SSH errors without process causes through the IPC boundary", () => { - const structured = new SshCommandTimeoutError({ - command: "ssh", - argumentCount: 0, - target: "devbox", - timeoutMs: 30_000, - }); - - assert.strictEqual(toDesktopSshIpcPresentationError(structured), structured); + return Effect.gen(function* () { + const exits = yield* Effect.all([ + Effect.exit(ensureSshEnvironment.handler({ target })), + Effect.exit(disconnectSshEnvironment.handler(target)), + ]); + for (const exit of exits) { + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, structured); + } + assert.equal(structured.message, "Failed to spawn SSH command for devbox."); + assert.strictEqual(structured.cause, cause); + }).pipe(Effect.provide(layer)); }); it.effect("fetches and decodes the remote environment descriptor", () => { diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 12aeb8e7cdb..f5c55d9b395 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -26,14 +26,7 @@ import { AuthSessionState, AuthWebSocketTicketResult, } from "@t3tools/contracts"; -import { - SshCommandExecutionError, - SshCommandSpawnError, - SshHttpBridgeError, - type SshPasswordPromptError, - SshTunnelMonitorError, - SshTunnelSpawnError, -} from "@t3tools/ssh/errors"; +import { SshHttpBridgeError, type SshPasswordPromptError } from "@t3tools/ssh/errors"; import { resolveLoopbackSshHttpBaseUrl } from "@t3tools/ssh/tunnel"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -83,26 +76,6 @@ const isEnvironmentOperationForbiddenError = Schema.is(EnvironmentOperationForbi const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); const isEnvironmentScopeRequiredError = Schema.is(EnvironmentScopeRequiredError); const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); -const isSshCausePresentationError = Schema.is( - Schema.Union([ - SshCommandSpawnError, - SshCommandExecutionError, - SshTunnelSpawnError, - SshTunnelMonitorError, - ]), -); - -// Electron only forwards the message from a rejected ipcRenderer.invoke promise. Keep the typed, -// structural SSH error intact as the cause for main-process diagnostics while preserving the -// actionable process message that the renderer historically presented to the user. -export function toDesktopSshIpcPresentationError( - error: DesktopSshEnvironment.DesktopSshEnvironmentOperationError, -): DesktopSshEnvironment.DesktopSshEnvironmentOperationError | Error { - if (isSshCausePresentationError(error) && error.cause instanceof Error) { - return new Error(error.cause.message, { cause: error }); - } - return error; -} function readSshHttpStatus(cause: DesktopSshEnvironmentRequestCause): number | null { if (isRemoteEnvironmentAuthUndeclaredStatusError(cause)) { @@ -169,6 +142,9 @@ export const discoverSshHosts = DesktopIpc.makeIpcMethod({ }), }); +// Electron forwards only the message from a rejected ipcRenderer.invoke promise to the renderer. +// Preserve typed SSH errors at this boundary so that message stays structural while main-process +// diagnostics retain the exact cause and error fields. export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ channel: IpcChannels.ENSURE_SSH_ENVIRONMENT_CHANNEL, payload: DesktopSshEnvironmentEnsureInputSchema, @@ -185,7 +161,6 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ SshPasswordPromptWindowClosedError: handleDesktopSshPasswordPromptCancellation, SshPasswordPromptServiceStoppedError: handleDesktopSshPasswordPromptCancellation, }), - Effect.mapError(toDesktopSshIpcPresentationError), ); }), }); @@ -196,9 +171,7 @@ export const disconnectSshEnvironment = DesktopIpc.makeIpcMethod({ result: Schema.Void, handler: Effect.fn("desktop.ipc.sshEnvironment.disconnectEnvironment")(function* (target) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; - yield* sshEnvironment - .disconnectEnvironment(target) - .pipe(Effect.mapError(toDesktopSshIpcPresentationError)); + yield* sshEnvironment.disconnectEnvironment(target); }), });