diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts index fa53486d5e2..38be93111e9 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.test.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.test.ts @@ -5,6 +5,7 @@ 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 { @@ -12,6 +13,8 @@ import { fetchSshEnvironmentDescriptor, } from "./sshEnvironment.ts"; +const isSshHttpBridgeError = Schema.is(SshHttpBridgeError); + function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { return HttpClientResponse.fromWeb( request, @@ -83,7 +86,7 @@ describe("SSH environment IPC", () => { assert.instanceOf(error, DesktopSshEnvironmentRequestError); assert.equal(error.operation, "fetch-environment-descriptor"); - assert.equal(error.cause instanceof SshHttpBridgeError, false); + assert.equal(isSshHttpBridgeError(error.cause), false); }).pipe(Effect.provide(layer)); }); diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index 9c9af2a4e2b..0e0604b5099 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -50,12 +50,10 @@ 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 ( - cause instanceof RemoteEnvironmentAuthUndeclaredStatusError || - cause instanceof SshHttpBridgeError - ) { + if (cause instanceof RemoteEnvironmentAuthUndeclaredStatusError || isSshHttpBridgeError(cause)) { return cause.status ?? null; } if (isEnvironmentRequestInvalidError(cause)) { diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index 31e84ae995e..cb763849692 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -21,6 +21,7 @@ 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"; @@ -69,6 +70,8 @@ export interface DesktopSshEnvironmentLayerOptions { readonly resolveCliRunner?: Effect.Effect; } +const isSshPasswordPromptError = Schema.is(SshPasswordPromptError); + function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) { return discoverSshHosts(input ?? {}); } @@ -77,7 +80,7 @@ export function isDesktopSshPasswordPromptCancellation( error: unknown, ): error is SshPasswordPromptError { return ( - error instanceof SshPasswordPromptError && + isSshPasswordPromptError(error) && DesktopSshPasswordPrompts.isDesktopSshPasswordPromptCancellation(error.cause) ); } diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829..105859447a4 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -258,6 +258,54 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("parses Windows process rows through Schema JSON decoding", () => + Effect.gen(function* () { + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.succeed( + mockHandle({ + stdout: JSON.stringify([ + { + ProcessId: 10, + ParentProcessId: 1, + Name: "node.exe", + CommandLine: "node server.js", + Status: "Running", + WorkingSetSize: 2048, + PercentProcessorTime: 12.5, + }, + { + ProcessId: "not-a-number", + ParentProcessId: 1, + Name: "ignored.exe", + }, + ]), + }), + ), + ), + ); + + const rows = yield* ProcessDiagnostics.readProcessRows.pipe( + Effect.provide(spawnerLayer), + Effect.provideService(HostProcessPlatform, "win32"), + ); + + assert.deepEqual(rows, [ + { + pid: 10, + ppid: 1, + pgid: null, + status: "Running", + cpuPercent: 12.5, + rssBytes: 2048, + elapsed: "", + command: "node server.js", + }, + ]); + }), + ); + it.effect("does not allow signaling the diagnostics query process", () => Effect.gen(function* () { const spawnerLayer = Layer.succeed( diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index b39d560a228..0df17ae9e4d 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -120,6 +120,9 @@ const ProcessDiagnosticsError = Schema.Union([ ]); type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type; const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError); +const decodeWindowsProcessRowsJson = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Unknown), +); function parsePositiveInt(value: string): number | null { const parsed = Number.parseInt(value, 10); @@ -236,16 +239,16 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { function parseWindowsProcessRows(output: string): ReadonlyArray { if (output.trim().length === 0) return []; - try { - const parsed = JSON.parse(output) as unknown; - const records = Array.isArray(parsed) ? parsed : [parsed]; - return records.flatMap((record) => { - const row = normalizeWindowsProcessRow(record); - return row ? [row] : []; - }); - } catch { - return []; - } + return Option.match(decodeWindowsProcessRowsJson(output), { + onNone: () => [], + onSome: (parsed) => { + const records = Array.isArray(parsed) ? parsed : [parsed]; + return records.flatMap((record) => { + const row = normalizeWindowsProcessRow(record); + return row ? [row] : []; + }); + }, + }); } export function buildDescendantEntries( diff --git a/packages/ssh/src/errors.ts b/packages/ssh/src/errors.ts index f1ba40b560c..350c7465f4a 100644 --- a/packages/ssh/src/errors.ts +++ b/packages/ssh/src/errors.ts @@ -1,47 +1,62 @@ -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", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + +export class SshInvalidTargetError extends Schema.TaggedErrorClass()( + "SshInvalidTargetError", + { + message: Schema.String, + }, +) {} + +export class SshCommandError extends Schema.TaggedErrorClass()("SshCommandError", { + message: Schema.String, + command: Schema.Array(Schema.String), + exitCode: Schema.NullOr(Schema.Number), + stderr: Schema.String, + stdout: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) {} + +export class SshLaunchError extends Schema.TaggedErrorClass()("SshLaunchError", { + message: Schema.String, + stdout: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export class SshPairingError extends Schema.TaggedErrorClass()("SshPairingError", { + message: Schema.String, + stdout: Schema.String, + cause: Schema.optional(Schema.Defect()), +}) {} + +export class SshHttpBridgeError extends Schema.TaggedErrorClass()( + "SshHttpBridgeError", + { + message: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class SshReadinessError extends Schema.TaggedErrorClass()( + "SshReadinessError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export class SshPasswordPromptError extends Schema.TaggedErrorClass()( + "SshPasswordPromptError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index e8c2b924759..b5ad04c8e06 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -56,6 +56,7 @@ const SSH_READY_PROBE_TIMEOUT_MS = 1_000; const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000; const REMOTE_READY_TIMEOUT_MS = 15_000; const REMOTE_REUSE_READY_TIMEOUT_MS = 2_000; +const isSshReadinessError = Schema.is(SshReadinessError); export interface RemoteT3RunnerOptions { readonly packageSpec?: string; @@ -233,7 +234,7 @@ function applyScriptPlaceholders( } export function describeReadinessCause(cause: unknown): unknown { - if (cause instanceof SshReadinessError) { + if (isSshReadinessError(cause)) { return { _tag: cause._tag, message: cause.message, @@ -922,7 +923,7 @@ export const waitForHttpReady = Effect.fn("ssh/tunnel.waitForHttpReady")(functio }); }).pipe( Effect.mapError((cause) => - cause instanceof SshReadinessError + isSshReadinessError(cause) ? cause : new SshReadinessError({ message: `Backend readiness probe failed at ${requestUrl}.`, @@ -943,7 +944,7 @@ 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}.`,