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); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index 88eb7c42782..a9cba3b1498 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -3,17 +3,24 @@ import { DesktopSshEnvironmentEnsureResultSchema, DesktopSshPasswordPromptCancellationError, } from "@t3tools/contracts"; -import { SshHttpBridgeError, SshPasswordPromptError } from "@t3tools/ssh/errors"; +import { + SshCommandSpawnError, + SshHttpBridgeError, + SshPasswordPromptWindowClosedError, +} from "@t3tools/ssh/errors"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; 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, + disconnectSshEnvironment, ensureSshEnvironment, fetchSshEnvironmentDescriptor, } from "./sshEnvironment.ts"; @@ -24,6 +31,9 @@ const decodeDesktopSshEnvironmentEnsureResult = Schema.decodeUnknownEffect( DesktopSshEnvironmentEnsureResultSchema, ); +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); +const isDesktopSshEnvironmentRequestError = Schema.is(DesktopSshEnvironmentRequestError); + function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( request, @@ -51,8 +61,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( @@ -84,6 +94,45 @@ describe("SSH environment IPC", () => { }).pipe(Effect.provide(layer)); }); + 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", + argumentCount: 0, + 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), + }), + ); + + 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", () => { const requestUrls: string[] = []; const layer = makeHttpClientLayer((request) => @@ -131,9 +180,10 @@ 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(error.cause instanceof SshHttpBridgeError, false); + assert.equal(isSshHttpBridgeError(error.cause), false); }).pipe(Effect.provide(layer)); }); @@ -157,8 +207,9 @@ describe("SSH environment IPC", () => { assert(Option.isSome(failure)); const error = failure.value; - assert.instanceOf(error, DesktopSshEnvironmentRequestError); - assert.instanceOf(error.cause, SshHttpBridgeError); + 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 69a3c32b1b5..f5c55d9b395 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 { @@ -26,9 +26,8 @@ import { AuthSessionState, AuthWebSocketTicketResult, } from "@t3tools/contracts"; -import { SshHttpBridgeError } from "@t3tools/ssh/errors"; +import { SshHttpBridgeError, type SshPasswordPromptError } 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"; @@ -37,13 +36,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", @@ -52,16 +57,31 @@ 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); 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; } @@ -80,14 +100,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}.`; } @@ -121,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, @@ -132,17 +156,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, }), ); }), diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.test.ts index 1fe2b86aae7..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 { SshPasswordPromptError } from "@t3tools/ssh/errors"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -35,18 +34,15 @@ 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 = 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."); }); 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/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 b4f7f78e28d..119d74c6755 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 { @@ -432,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) { @@ -1283,6 +1309,13 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => relayClient .installWithProgress((event) => Queue.offer(queue, event).pipe(Effect.asVoid)) .pipe( + Effect.mapError( + (error) => + new RelayClientInstallFailedError({ + reason: relayClientInstallFailureReason(error), + cause: error, + }), + ), Effect.flatMap((status) => Queue.offer(queue, { type: "complete", @@ -1290,14 +1323,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => }), ), Effect.catchTags({ - RelayClientInstallError: (error) => - Queue.fail( - queue, - new RelayClientInstallFailedError({ - reason: error.reason, - message: error.message, - }), - ), + RelayClientInstallFailedError: (error) => Queue.fail(queue, error), }), Effect.andThen(Queue.end(queue)), Effect.forkScoped, 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()( "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), 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 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"); - }), - ); }); diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts index 93a1649b25b..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,21 +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({ message: "Failed to open test server", cause })), - ); + settle(Effect.fail(cause)); }); if (host) { @@ -56,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 d7713a72612..50bb65e4580 100644 --- a/packages/shared/src/Net.ts +++ b/packages/shared/src/Net.ts @@ -1,15 +1,57 @@ 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"; +import * as Schema from "effect/Schema"; + +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 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 class NetError extends Data.TaggedError("NetError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export const NetError = Schema.Union([ + LoopbackPortListenError, + LoopbackPortAddressUnavailableError, + LoopbackPortReleaseError, +]); +export type NetError = typeof NetError.Type; const isErrnoExceptionWithCode = ( cause: unknown, @@ -20,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(); @@ -28,36 +73,41 @@ 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; +/** + * NetService - Service tag for startup networking helpers. + */ +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; + /** + * 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; + /** + * 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; -} + /** + * Resolve an available listening port, preferring the provided port first. + */ + readonly findAvailablePort: (preferred: number) => Effect.Effect; + } +>()("@t3tools/shared/Net/NetService") {} -/** - * NetService - Service tag for startup networking helpers. - */ -export class NetService extends Context.Service()( - "@t3tools/shared/Net/NetService", -) {} +export const make = ( + options: { + readonly createServer?: () => NodeNet.Server; + } = {}, +) => { + const createServer = options.createServer ?? NodeNet.createServer; -export const make = () => { /** * Returns true when a TCP server can bind to {host, port}. * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail @@ -65,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) => { @@ -150,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; @@ -160,27 +217,80 @@ export const make = () => { }; probe.once("error", (cause) => { - settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", 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({ message: "Failed to reserve loopback port" }))); + }); }); - }); + } catch (cause) { + settle(Effect.fail(new LoopbackPortListenError({ host, cause }))); + } return Effect.sync(() => { closeServer(probe); }); }); - return { + return NetService.of({ canListenOnHost, isPortAvailableOnLoopback, reserveLoopbackPort, @@ -191,7 +301,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..a789a2c88a8 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( @@ -41,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 })), ), ), ); @@ -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( @@ -192,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"); @@ -200,7 +249,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 +285,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 +303,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..c35abaa5201 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,10 +13,16 @@ 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"; +import { getUrlDiagnostics } from "./urlDiagnostics.ts"; export const CLOUDFLARED_VERSION = "2026.5.2"; export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; @@ -44,23 +49,248 @@ 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; -}> {} +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", + { + ...RelayClientUrlDiagnosticFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Could not download the relay client."; + } +} + +export class RelayClientDownloadReadError extends Schema.TaggedErrorClass()( + "RelayClientDownloadReadError", + { + ...RelayClientUrlDiagnosticFields, + 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", + { + ...RelayClientUrlDiagnosticFields, + 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 +353,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"; @@ -150,29 +379,10 @@ function isAlreadyExists(error: PlatformError.PlatformError): boolean { return error.reason._tag === "AlreadyExists"; } -const wrapInstallFailure = - ( - reason: RelayClientInstallError["reason"], - message: string, - ): (( - effect: Effect.Effect, - ) => Effect.Effect) => - (effect) => - effect.pipe( - Effect.mapError( - (cause) => - new RelayClientInstallError({ - reason, - message, - cause, - }), - ), - ); - export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function* ( options: CloudflaredRelayClientOptions, ): Effect.fn.Return< - RelayClientShape, + RelayClient["Service"], never, | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -221,7 +431,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 +496,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({ + ...relayClientUrlDiagnostics(asset.url), cause, }), ), @@ -297,9 +506,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({ + ...relayClientUrlDiagnostics(asset.url), cause, }), ), @@ -309,17 +517,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({ + ...relayClientUrlDiagnostics(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 +555,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,37 +568,40 @@ 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* 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.", + yield* fileSystem.makeDirectory(managedDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new RelayClientDirectoryCreateError({ + directoryPath: managedDirectory, cause, }), - ), ), ); + yield* report("waiting_for_lock"); + yield* acquireInstallLock(lockPath).pipe( + Effect.catchTags({ + PlatformError: (cause) => + Effect.fail( + new RelayClientInstallLockAcquireError({ + lockPath, + cause, + }), + ), + }), + ); return yield* Effect.gen(function* () { const afterLock = yield* resolve; if (afterLock.status === "available") return afterLock; @@ -405,37 +616,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( + Effect.mapError( + (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."), + Effect.mapError( + (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( + Effect.mapError( + (cause) => + new RelayClientExecutablePermissionError({ + executablePath, + cause, + }), + ), + ); } yield* report("validating"); yield* runCommand(executablePath, ["--version"]).pipe( - wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), + Effect.mapError( + (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( + Effect.mapError( + (cause) => + new RelayClientStageError({ + sourcePath: executablePath, + destinationPath: stagedPath, + cause, + }), + ), + ); + yield* fileSystem.rename(stagedPath, managedPath).pipe( + Effect.mapError( + (cause) => + new RelayClientActivationError({ + sourcePath: stagedPath, + destinationPath: managedPath, + cause, + }), + ), + Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)), + ); return { status: "available", executablePath: managedPath, @@ -445,20 +693,20 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }).pipe( Effect.scoped, Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), - Effect.catch((cause) => - cause instanceof RelayClientInstallError - ? Effect.fail(cause) - : Effect.fail( - new RelayClientInstallError({ - reason: "write_failed", - message: "Could not install the relay client.", - cause, - }), - ), + Effect.catch( + (cause): Effect.Effect => + isRelayClientInstallError(cause) + ? Effect.fail(cause) + : Effect.fail( + new RelayClientInstallWriteError({ + managedPath, + cause, + }), + ), ), ); }); - const installWithProgress: RelayClientShape["installWithProgress"] = (report) => + const installWithProgress: RelayClient["Service"]["installWithProgress"] = (report) => installSemaphore.withPermit( installUnlocked((stage) => report({ 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 cf2f2417ff4..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* ( @@ -604,7 +647,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/auth.test.ts b/packages/ssh/src/auth.test.ts index 3cd42ad4382..f5dbfc5ac36 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,59 @@ 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", + argumentCount: 1, + target: "devbox", + cause: authFailure, + }); + assert.equal(isSshAuthFailure(commandFailure), true); + + const helperFailure = new SshErrors.SshAuthenticationHelperError({ + target: "devbox", + cause: authFailure, + }); + assert.equal(isSshAuthFailure(helperFailure), false); + + const readinessFailure = new SshErrors.SshReadinessTimeoutError({ + 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({ + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, + 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 ef78b2f24fe..c9d9c147fa0 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 { SshPasswordPromptError } from "./errors.ts"; +import * as SshErrors from "./errors.ts"; export interface SshPasswordRequest { readonly destination: string; @@ -34,24 +35,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; @@ -204,8 +206,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( @@ -215,3 +216,47 @@ export function isSshAuthFailure(error: unknown): boolean { /too many authentication failures/u.test(normalized) ); } + +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, + SshErrors.SshCommandExecutionError, + SshErrors.SshTunnelSpawnError, + SshErrors.SshTunnelMonitorError, + ]), +); + +export function isSshAuthFailure(error: unknown): boolean { + const visited = new Set(); + let current = error; + + 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; + } + if (!isSshAuthFailureCauseWrapper(current)) { + return false; + } + current = current.cause; + } + + return false; +} diff --git a/packages/ssh/src/command.test.ts b/packages/ssh/src/command.test.ts index e5b621f87aa..f87bc527d2d 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 } from "./errors.ts"; const encoder = new TextEncoder(); @@ -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" })), ); @@ -167,15 +167,33 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, SshCommandError); - 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.instanceOf(result.failure, SshCommandExitError); + 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", + argumentCount: 0, + 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)); }); - 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' })), ); @@ -197,9 +215,13 @@ describe("ssh command", () => { assert.isTrue(Result.isFailure(result)); if (Result.isFailure(result)) { - assert.instanceOf(result.failure, SshCommandError); - assert.equal(result.failure.message, '{"credential":"[redacted]"}'); - assert.equal(result.failure.stdout, '{"credential":"[redacted]"}\n'); + assert.instanceOf(result.failure, SshCommandExitError); + 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)); }); @@ -214,7 +236,7 @@ describe("ssh command", () => { Effect.result( runSshCommand( { - alias: "devbox", + alias: " ", hostname: "devbox.example.com", username: "julius", port: 2222, @@ -230,7 +252,10 @@ 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.example.com.", + ); } }).pipe(Effect.provide(processLayer)); }); diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b43089..8086b9e907e 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -9,14 +9,24 @@ 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; -const MAX_SSH_ERROR_OUTPUT_LENGTH = 4_000; /** * ssh is a real executable everywhere (`ssh.exe` on Windows), so it is always @@ -30,12 +40,16 @@ 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; } -export interface RunSshCommandOptions extends SshAuthOptions { +export interface RunSshCommandOptions extends SshAuth.SshAuthOptions { readonly preHostArgs?: ReadonlyArray; readonly remoteCommandArgs?: ReadonlyArray; readonly stdin?: string; @@ -93,9 +107,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, }), }); @@ -133,30 +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 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 +171,14 @@ 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({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: "Failed to prepare SSH authentication helpers.", + new SshAuthenticationHelperError({ + target: hostSpec, cause, }), ), @@ -207,7 +195,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, }); @@ -226,14 +215,10 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func Effect.provideService(Scope.Scope, commandScope), Effect.mapError( (cause) => - new SshCommandError({ - command: [sshCommand, ...args], - exitCode: null, - stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to spawn SSH command for ${hostSpec}.`, + new SshCommandSpawnError({ + command: sshCommand, + argumentCount: args.length, + target: hostSpec, cause, }), ), @@ -249,42 +234,39 @@ const runSshCommandInScope = Effect.fn("ssh/command.runSshCommand.inScope")(func ).pipe( Effect.mapError( (cause) => - new SshCommandError({ - command: ["ssh", ...args], - exitCode: null, - stderr: "", - message: - cause instanceof Error ? cause.message : `Failed to run SSH command for ${hostSpec}.`, + new SshCommandExecutionError({ + command: sshCommand, + argumentCount: args.length, + target: hostSpec, cause, }), ), ); 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 SshCommandError({ - command: ["ssh", ...args], + return yield* new SshCommandExitError({ + command: sshCommand, + argumentCount: args.length, exitCode, - stdout: diagnosticStdout, - stderr, - message: normalizeSshErrorMessage({ - stdout: diagnosticStdout, - stderr, - fallbackMessage: `SSH command failed for ${hostSpec} (exit ${exitCode}).`, - }), + stdoutBytes: utf8ByteLength(stdout), + stderrBytes: utf8ByteLength(stderr), + target: hostSpec, + reason: SshAuth.classifySshProcessExit({ stdout, stderr }), }); } yield* Effect.logDebug("ssh.command.succeeded", { ...sshTargetLogFields(target), - command: ["ssh", ...args], + command: sshCommand, + argumentCount: args.length, }); return { stdout, stderr }; }); @@ -309,15 +291,22 @@ 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 SshCommandError({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: `SSH command timed out after ${input.timeoutMs ?? DEFAULT_SSH_COMMAND_TIMEOUT_MS}ms.`, + return yield* new SshCommandTimeoutError({ + command: "ssh", + argumentCount: + baseSshArgs(target).length + + (input.preHostArgs?.length ?? 0) + + 1 + + (input.remoteCommandArgs?.length ?? 0), + target: target.alias.trim() || target.hostname.trim(), + 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 }); @@ -352,7 +341,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/config.test.ts b/packages/ssh/src/config.test.ts index 0446370337a..1481a022705 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, "Failed to discover SSH hosts."); + 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..3998d970408 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,47 +1,475 @@ -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 "Failed to discover SSH hosts."; + } +} + +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 SshCommandContextFields = { + command: Schema.String, + argumentCount: Schema.Number, + target: Schema.String, +}; + +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", + { + target: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to prepare SSH authentication helpers for ${this.target}.`; + } +} + +export class SshCommandSpawnError extends Schema.TaggedErrorClass()( + "SshCommandSpawnError", + { + ...SshCommandCauseFields, + }, +) { + override get message(): string { + return `Failed to spawn SSH command for ${this.target}.`; + } +} + +export class SshCommandExecutionError extends Schema.TaggedErrorClass()( + "SshCommandExecutionError", + { + ...SshCommandCauseFields, + }, +) { + override get message(): string { + return `Failed to run SSH command for ${this.target}.`; + } +} + +export class SshCommandExitError extends Schema.TaggedErrorClass()( + "SshCommandExitError", + { + ...SshCommandContextFields, + exitCode: Schema.Number, + stdoutBytes: Schema.Number, + stderrBytes: Schema.Number, + reason: SshProcessExitReason, + }, +) { + override get message(): string { + return `SSH command failed for ${this.target} (exit ${this.exitCode}).`; + } +} + +export class SshCommandTimeoutError extends Schema.TaggedErrorClass()( + "SshCommandTimeoutError", + { + ...SshCommandContextFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `SSH command timed out after ${this.timeoutMs}ms for ${this.target}.`; + } +} + +export class SshCommandCancelledError extends Schema.TaggedErrorClass()( + "SshCommandCancelledError", + { + target: Schema.String, + }, +) { + override get message(): string { + return `SSH environment connection was cancelled for ${this.target}.`; + } +} + +export class SshTunnelSpawnError extends Schema.TaggedErrorClass()( + "SshTunnelSpawnError", + { + ...SshCommandCauseFields, + }, +) { + override get message(): string { + return `Failed to spawn SSH tunnel for ${this.target}.`; + } +} + +export class SshTunnelMonitorError extends Schema.TaggedErrorClass()( + "SshTunnelMonitorError", + { + ...SshCommandCauseFields, + }, +) { + override get message(): string { + return `Failed to monitor SSH tunnel for ${this.target}.`; + } +} + +export class SshTunnelExitError extends Schema.TaggedErrorClass()( + "SshTunnelExitError", + { + ...SshCommandContextFields, + exitCode: Schema.Number, + stderrBytes: Schema.Number, + reason: SshProcessExitReason, + }, +) { + override get message(): string { + return `SSH tunnel exited unexpectedly for ${this.target} (exit ${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", + { + target: Schema.String, + stdoutBytes: Schema.Number, + }, +) { + override get message(): string { + 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 for ${this.target} returned unparseable output.`; + } +} + +export class SshLaunchInvalidPortError extends Schema.TaggedErrorClass()( + "SshLaunchInvalidPortError", + { + target: Schema.String, + stdoutBytes: Schema.Number, + remotePort: Schema.Number, + }, +) { + override get message(): string { + return `SSH launch for ${this.target} returned an invalid remote port: ${this.remotePort}.`; + } +} + +export const SshLaunchError = Schema.Union([ + SshLaunchPortMissingError, + SshLaunchOutputParseError, + SshLaunchInvalidPortError, +]); +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 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 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 for ${this.target} returned an invalid credential.`; + } +} + +export const SshPairingError = Schema.Union([ + SshPairingCredentialMissingError, + SshPairingOutputParseError, + SshPairingInvalidCredentialError, +]); +export type SshPairingError = typeof SshPairingError.Type; + +export class SshHttpBridgeMissingUrlError extends Schema.TaggedErrorClass()( + "SshHttpBridgeMissingUrlError", + {}, +) { + override get message(): string { + return "Invalid SSH forwarded http base URL."; + } +} + +export class SshHttpBridgeInvalidUrlError extends Schema.TaggedErrorClass()( + "SshHttpBridgeInvalidUrlError", + { + cause: 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."; + } +} + +export const SshHttpBridgeError = Schema.Union([ + SshHttpBridgeMissingUrlError, + SshHttpBridgeInvalidUrlError, + SshHttpBridgeNonLoopbackUrlError, +]); +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", + { + request: SshReadinessUrlDiagnostics, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Backend readiness probe failed at ${readinessTarget(this.request)}.`; + } +} + +export class SshReadinessProbeTimeoutError extends Schema.TaggedErrorClass()( + "SshReadinessProbeTimeoutError", + { + request: SshReadinessUrlDiagnostics, + timeoutMs: Schema.Number, + attempt: Schema.Number, + }, +) { + override get message(): string { + return `Backend readiness probe exceeded ${this.timeoutMs}ms at ${readinessTarget(this.request)}.`; + } +} + +export class SshReadinessTimeoutError extends Schema.TaggedErrorClass()( + "SshReadinessTimeoutError", + { + 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 ${readinessTarget(this.base)}.`; + } +} + +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; diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 7e7a5a54276..9e2af3d4f4d 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -5,26 +5,30 @@ 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 { TestClock } from "effect/testing"; -import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { SshPasswordPrompt } from "./auth.ts"; +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"; + +import * as SshAuth from "./auth.ts"; import { - buildRemoteLaunchScript, - buildRemotePairingScript, - buildRemoteStopScript, - buildRemoteT3RunnerScript, - describeReadinessCause, - issueRemotePairingToken, - launchOrReuseRemoteServer, - REMOTE_PICK_PORT_SCRIPT, - SshEnvironmentManager, - waitForHttpReady, -} from "./tunnel.ts"; + SshHttpBridgeInvalidUrlError, + SshHttpBridgeMissingUrlError, + SshHttpBridgeNonLoopbackUrlError, + SshPairingOutputParseError, + SshReadinessProbeError, + SshReadinessProbeTimeoutError, + SshReadinessTimeoutError, + SshTunnelSpawnError, +} from "./errors.ts"; +import * as SshTunnel from "./tunnel.ts"; const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; @@ -45,6 +49,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({ @@ -88,9 +112,17 @@ 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 = 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 +148,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 +165,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 +185,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 +278,31 @@ 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* () { + 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( - waitForHttpReady({ - baseUrl: "http://127.0.0.1:41773/", + SshTunnel.waitForHttpReady({ + baseUrl, + path, timeoutMs: 1_000, intervalMs: 100, probeTimeoutMs: 250, @@ -257,18 +316,161 @@ 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.deepEqual(timeoutError.base, { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: baseUrl.length, + pathnameLength: new TextEncoder().encode(new URL(baseUrl).pathname).length, + hasQuery: true, + hasFragment: true, + }); + assert.deepEqual(timeoutError.request, { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: requestUrl.length, + pathnameLength: new TextEncoder().encode(new URL(requestUrl).pathname).length, + 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.", + ); + 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); + + 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.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.merge(TestClock.layer(), Layer.succeed(HttpClient.HttpClient, hangingHttpClient)), + 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* () { + 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 SSH forwarded http base 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", () => { + it("describes readiness causes without copying arbitrary messages", () => { assert.deepEqual( - describeReadinessCause({ + SshTunnel.describeReadinessCause({ _tag: "HttpClientError", message: "Backend readiness probe failed.", reason: "authentication failed", @@ -276,9 +478,47 @@ describe("ssh tunnel scripts", () => { }), { _tag: "HttpClientError", - message: "Backend readiness probe failed.", - reason: "authentication failed", - cause: "upstream closed", + reason: { type: "string" }, + 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", () => { + assert.deepEqual( + SshTunnel.describeReadinessCause( + new SshReadinessProbeTimeoutError({ + request: { + protocol: "http:", + hostname: "127.0.0.1", + port: "41773", + urlLength: 28, + pathnameLength: 6, + hasQuery: false, + hasFragment: false, + }, + timeoutMs: 250, + attempt: 3, + }), + ), + { + _tag: "SshReadinessProbeTimeoutError", + 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.", }, ); }); @@ -305,7 +545,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,15 +573,201 @@ 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)); }); + 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.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"); + 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", + 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("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("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; 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); @@ -366,8 +792,9 @@ 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(), + Logger.layer([logger], { mergeWithExisting: false }), ); const target = { alias: "devbox", @@ -377,7 +804,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/"); @@ -390,6 +817,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 e8c2b924759..7f9ededa83a 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"; @@ -19,15 +21,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, @@ -38,17 +37,39 @@ import { resolveSshTarget, runSshCommand, targetConnectionKey, + utf8ByteLength, } from "./command.ts"; import { + SshAuthenticationHelperError, + SshCommandCancelledError, SshCommandError, + SshHttpBridgeInvalidUrlError, + SshHttpBridgeMissingUrlError, + 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 +107,7 @@ type SshEnvironmentEffectContext = | Path.Path | HttpClient.HttpClient | NetService.NetService - | SshPasswordPrompt; + | SshAuth.SshPasswordPrompt; type SshEnvironmentEffectError = | SshCommandError @@ -97,15 +118,6 @@ type SshEnvironmentEffectError = | SshPasswordPromptError | NetService.NetError; -function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { - return new SshCommandError({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`, - }); -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -129,7 +141,7 @@ interface SshAuthOperationInput { readonly key: string; readonly target: DesktopSshEnvironmentTarget; readonly operation: ( - authOptions: SshAuthOptions, + authOptions: SshAuth.SshAuthOptions, ) => Effect.Effect; } @@ -138,19 +150,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 +226,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, ""); } @@ -232,31 +245,69 @@ function applyScriptPlaceholders( return result; } -export function describeReadinessCause(cause: unknown): unknown { - if (cause instanceof SshReadinessError) { +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 { - _tag: cause._tag, + 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; + }; + return { + ...attributes, message: cause.message, - ...(cause.cause === undefined ? {} : { cause: describeReadinessCause(cause.cause) }), + ...(nestedCause === undefined ? {} : { cause: describeNested(nestedCause) }), }; } if (cause instanceof Error) { + const tagged = cause as Error & { readonly _tag?: unknown; readonly reason?: unknown }; return { - name: cause.name, - message: cause.message, - ...(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) { - 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) }), + ...(record.reason === undefined ? {} : { reason: describeNested(record.reason) }), + ...(record.cause === undefined ? {} : { cause: describeNested(record.cause) }), + }; +} + +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: diagnostics.protocol ?? url.protocol, + hostname: diagnostics.hostname ?? url.hostname, + ...(url.port === "" ? {} : { port: url.port }), + urlLength: diagnostics.inputLength, + pathnameLength: utf8ByteLength(url.pathname), + hasQuery: url.search !== "", + hasFragment: url.hash !== "", }; } @@ -714,13 +765,14 @@ 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 }, 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), @@ -734,25 +786,26 @@ 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.", - stdout: result.stdout, + return yield* new SshLaunchPortMissingError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), }); } const parsed = yield* decodeRemoteLaunchOutput(result.stdout).pipe( Effect.mapError( (cause) => - new SshLaunchError({ - message: "SSH launch returned unparseable output.", - stdout: result.stdout, + new SshLaunchOutputParseError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), cause, }), ), ); if (!Number.isInteger(parsed.remotePort)) { - return yield* new SshLaunchError({ - message: `SSH launch returned an invalid remote port: ${String(parsed.remotePort)}.`, - stdout: result.stdout, + return yield* new SshLaunchInvalidPortError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), + remotePort: parsed.remotePort, }); } yield* Effect.logInfo("ssh.remoteServer.launch.ready", { @@ -770,7 +823,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< { @@ -779,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), @@ -791,25 +845,25 @@ 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.", - stdout: result.stdout, + return yield* new SshPairingCredentialMissingError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), }); } const parsed = yield* decodeRemotePairingOutput(result.stdout).pipe( Effect.mapError( (cause) => - new SshPairingError({ - message: "SSH pairing returned unparseable output.", - stdout: result.stdout, + new SshPairingOutputParseError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), cause, }), ), ); if (parsed.credential.trim().length === 0) { - return yield* new SshPairingError({ - message: "SSH pairing command returned an invalid credential.", - stdout: result.stdout, + return yield* new SshPairingInvalidCredentialError({ + target: destination, + stdoutBytes: utf8ByteLength(result.stdout), }); } yield* Effect.logDebug("ssh.remoteServer.pairingToken.created", { @@ -823,7 +877,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 +902,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, @@ -878,14 +932,18 @@ 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 base = readinessUrlDiagnostics(input.baseUrl); + const requestDiagnostics = readinessUrlDiagnostics(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, + base, + request: requestDiagnostics, timeoutMs, intervalMs, probeTimeoutMs, @@ -900,8 +958,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({ + request: requestDiagnostics, cause, }), ), @@ -910,31 +968,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({ + request: requestDiagnostics, + timeoutMs: probeTimeoutMs, + attempt, }), ), }); }).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause - : new SshReadinessError({ - message: `Backend readiness probe failed at ${requestUrl}.`, + : new SshReadinessProbeError({ + request: requestDiagnostics, 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 +993,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({ + request: requestDiagnostics, cause, }), ), @@ -956,25 +1006,28 @@ 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, + base, + request: requestDiagnostics, attempts: attempt, }), onNone: () => Effect.gen(function* () { const lastFailure = yield* Ref.get(lastProbeFailure); yield* Effect.logWarning("ssh.tunnel.httpReady.timedOut", { - baseUrl: input.baseUrl, - requestUrl, + base, + request: requestDiagnostics, timeoutMs, 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({ + base, + request: requestDiagnostics, + timeoutMs, + attempts: attempt, + ...(lastFailure === null ? {} : { cause: lastFailure }), }); }), }); @@ -990,23 +1043,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 SshHttpBridgeMissingUrlError(); + } + const baseUrl = yield* Effect.try({ + try: () => new URL(rawHttpBaseUrl), + catch: (cause) => new SshHttpBridgeInvalidUrlError({ cause }), }); + if (!isLoopbackHostname(baseUrl.hostname)) { + return yield* new SshHttpBridgeNonLoopbackUrlError({ + hostname: baseUrl.hostname, + }); + } + return baseUrl.toString(); }, ); @@ -1022,7 +1071,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 +1084,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: | Scope.Scope > { const hostSpec = yield* buildSshHostSpecEffect(input.resolvedTarget); - const childEnvironment = yield* buildSshChildEnvironment({ + const destination = input.resolvedTarget.alias.trim() || input.resolvedTarget.hostname.trim(); + const childEnvironment = yield* SshAuth.buildSshChildEnvironment({ ...(input.authOptions.authSecret === undefined ? {} : { authSecret: input.authOptions.authSecret }), @@ -1045,11 +1095,8 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: }).pipe( Effect.mapError( (cause) => - new SshCommandError({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: "Failed to prepare SSH authentication helpers.", + new SshAuthenticationHelperError({ + target: hostSpec, cause, }), ), @@ -1071,16 +1118,17 @@ 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; + const httpBaseUrlDiagnostics = getUrlDiagnostics(input.httpBaseUrl); 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, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }); const child = yield* spawner .spawn( @@ -1096,25 +1144,22 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: .pipe( Effect.mapError( (cause) => - new SshCommandError({ - command: tunnelCommand, - exitCode: null, - stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to spawn SSH tunnel for ${input.resolvedTarget.alias}.`, + new SshTunnelSpawnError({ + command: sshCommand, + argumentCount: args.length, + target: destination, cause, }), ), ); 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, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }); const tunnelEntry: SshTunnelEntry = { key: input.key, @@ -1133,36 +1178,32 @@ const startSshTunnel = Effect.fn("ssh/tunnel.startSshTunnel")(function* (input: ).pipe( Effect.mapError( (cause) => - new SshCommandError({ - command: tunnelCommand, - exitCode: null, - stderr: "", - message: - cause instanceof Error - ? cause.message - : `Failed to monitor SSH tunnel for ${input.resolvedTarget.alias}.`, + new SshTunnelMonitorError({ + command: sshCommand, + argumentCount: args.length, + target: destination, cause, }), ), Effect.flatMap(([stderr, exitCode]) => { - const error = new SshCommandError({ - command: tunnelCommand, + const error = new SshTunnelExitError({ + command: sshCommand, + argumentCount: args.length, exitCode, - stderr, - message: normalizeSshErrorMessage( - stderr, - `SSH tunnel exited unexpectedly for ${input.resolvedTarget.alias} (exit ${exitCode}).`, - ), + stderrBytes: utf8ByteLength(stderr), + target: destination, + reason: SshAuth.classifySshProcessExit({ stdout: "", stderr }), }); 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, + httpBaseUrlDiagnostics, exitCode, - stderr, + stderrBytes: utf8ByteLength(stderr), }).pipe(Effect.andThen(Effect.fail(error))); }), ); @@ -1176,11 +1217,12 @@ 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, - httpBaseUrl: input.httpBaseUrl, + httpBaseUrlDiagnostics, }), ), Effect.tapError((cause) => @@ -1202,24 +1244,25 @@ 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) ? {} - : { 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 }), - ...(remoteLogTail === null ? {} : { remoteLogTail }), + : { localPortProbeFailure: describeReadinessCause(localPortAvailableExit.cause) }), + ...(remoteLogTail === null ? {} : { remoteLogTailBytes: utf8ByteLength(remoteLogTail) }), ...(Exit.isSuccess(remoteLogTailExit) ? {} - : { remoteLogTailError: remoteLogTailExit.cause }), - cause, + : { remoteLogTailFailure: describeReadinessCause(remoteLogTailExit.cause) }), + failure: describeReadinessCause(cause), }); }), ), @@ -1237,9 +1280,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< @@ -1275,7 +1318,12 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma return; } pendingTunnelEntries.delete(key); - yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); + yield* Deferred.fail( + pending, + new SshCommandCancelledError({ + target: target.alias || target.hostname, + }), + ).pipe(Effect.ignore); }); yield* Scope.addFinalizer( @@ -1291,16 +1339,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 +1371,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 +1388,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; } @@ -1344,9 +1396,9 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshTargetLogFields(input.target), key: input.key, promptCount: input.promptCount, - cause: input.error, + failure: describeReadinessCause(input.error), }); - const promptService = yield* SshPasswordPrompt; + const promptService = yield* SshAuth.SshPasswordPrompt; if (!promptService.isAvailable) { return yield* input.error; } @@ -1371,7 +1423,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 ? { @@ -1543,7 +1595,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma key, localPort: entry.localPort, remotePort: entry.remotePort, - cause: readinessExit.cause, + failure: describeReadinessCause(readinessExit.cause), }); yield* closeTunnelEntry(entry); yield* cancelPendingTunnelEntry(key, resolvedTarget); @@ -1571,7 +1623,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma Effect.logWarning("ssh.environment.tunnel.create.failed", { ...sshTargetLogFields(resolvedTarget), key, - cause, + failure: describeReadinessCause(cause), }), ), Effect.onExit((exit) => @@ -1686,13 +1738,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));