From 4a085d04db165947758a3bd2c9b82cb5c85109ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 07:14:27 -0700 Subject: [PATCH 1/2] fix(desktop): retain shell environment probe failures Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.test.ts | 56 +++++++++++++++- .../src/shell/DesktopShellEnvironment.ts | 67 ++++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 195902c3c92..a7c5462cdee 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,15 +1,23 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; const textEncoder = new TextEncoder(); +const isDesktopShellEnvironmentCommandError = Schema.is( + DesktopShellEnvironment.DesktopShellEnvironmentCommandError, +); + function envOutput(values: Readonly>): string { return Object.entries(values) .flatMap(([name, value]) => [ @@ -59,6 +67,7 @@ function runShellEnvironment(input: { readonly env: NodeJS.ProcessEnv; readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; + readonly failure?: PlatformError.PlatformError; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, @@ -68,7 +77,11 @@ function runShellEnvironment(input: { ); const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => Effect.succeed(makeProcess(input.handler(command)))), + ChildProcessSpawner.make((command) => + input.failure === undefined + ? Effect.succeed(makeProcess(input.handler(command))) + : Effect.fail(input.failure), + ), ); const program = Effect.gen(function* () { @@ -229,4 +242,43 @@ describe("DesktopShellEnvironment", () => { ); }), ); + + it.effect("logs command failures with the invocation and exact cause", () => { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/bash", + PATH: "/usr/bin", + }; + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + pathOrDescriptor: "/bin/bash", + }); + const messages: Array = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return runShellEnvironment({ + env, + platform: "linux", + handler: () => "", + failure: cause, + }).pipe( + Effect.andThen( + Effect.sync(() => { + const errors = messages + .flatMap((message) => (Array.isArray(message) ? message : [message])) + .filter(isDesktopShellEnvironmentCommandError); + assert.lengthOf(errors, 1); + assert.equal(errors[0]?.command, "/bin/bash"); + assert.equal(errors[0]?.args[0], "-ilc"); + assert.include(errors[0]?.args[1] ?? "", "__T3CODE_ENV_PATH"); + assert.equal(errors[0]?.cause, cause); + assert.notInclude(errors[0]?.message ?? "", cause.message); + }), + ), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 62a3b6efc91..f20c250ff0b 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -20,6 +21,35 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } +const desktopShellEnvironmentCommandFields = { + command: Schema.String, + args: Schema.Array(Schema.String), +}; + +export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandError", + { + ...desktopShellEnvironmentCommandFields, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop shell environment command ${this.command} failed.`; + } +} + +export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErrorClass()( + "DesktopShellEnvironmentCommandTimeoutError", + { + ...desktopShellEnvironmentCommandFields, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Desktop shell environment command ${this.command} timed out after ${this.timeoutMs}ms.`; + } +} + export class DesktopShellEnvironment extends Context.Service< DesktopShellEnvironment, { @@ -127,6 +157,16 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; +const logShellEnvironmentCommandError = ( + error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, +) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + component: "desktop-shell-environment", + error, + }), + ); + const capturePosixEnvironmentCommand = (names: ReadonlyArray) => names .map((name) => { @@ -181,7 +221,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly shell?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* spawner + const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { shell: input.shell ?? false, @@ -193,10 +233,31 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( }), ) .pipe( + Effect.mapError( + (cause) => + new DesktopShellEnvironmentCommandError({ + command: input.command, + args: input.args, + cause, + }), + ), + Effect.catchTags({ + DesktopShellEnvironmentCommandError: (error) => + logShellEnvironmentCommandError(error).pipe(Effect.as("")), + }), Effect.timeoutOption(input.timeout), - Effect.map(Option.getOrElse(() => "")), - Effect.orElseSucceed(() => ""), ); + if (Option.isSome(output)) { + return output.value; + } + + const error = new DesktopShellEnvironmentCommandTimeoutError({ + command: input.command, + args: input.args, + timeoutMs: Duration.toMillis(input.timeout), + }); + yield* logShellEnvironmentCommandError(error); + return ""; }); const readLoginShellEnvironment = ( From dab1a98f03f9bd19b57093385aba35a8467981f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 09:38:18 -0700 Subject: [PATCH 2/2] fix(desktop): sanitize shell probe diagnostics Co-authored-by: codex --- .../src/shell/DesktopShellEnvironment.test.ts | 9 ++--- .../src/shell/DesktopShellEnvironment.ts | 33 ++++++++++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index a7c5462cdee..7ec0ab80ae7 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -243,7 +243,7 @@ describe("DesktopShellEnvironment", () => { }), ); - it.effect("logs command failures with the invocation and exact cause", () => { + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", PATH: "/usr/bin", @@ -271,9 +271,10 @@ describe("DesktopShellEnvironment", () => { .flatMap((message) => (Array.isArray(message) ? message : [message])) .filter(isDesktopShellEnvironmentCommandError); assert.lengthOf(errors, 1); - assert.equal(errors[0]?.command, "/bin/bash"); - assert.equal(errors[0]?.args[0], "-ilc"); - assert.include(errors[0]?.args[1] ?? "", "__T3CODE_ENV_PATH"); + assert.equal(errors[0]?.probe, "login-shell"); + assert.equal(errors[0]?.executable, "bash"); + assert.equal(errors[0]?.argumentCount, 2); + assert.notProperty(errors[0] ?? {}, "args"); assert.equal(errors[0]?.cause, cause); assert.notInclude(errors[0]?.message ?? "", cause.message); }), diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index f20c250ff0b..8219f18b7a5 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -21,9 +21,18 @@ interface WindowsProbeOptions { readonly loadProfile: boolean; } +const DesktopShellEnvironmentProbe = Schema.Literals([ + "login-shell", + "launchctl-path", + "powershell-profile", + "powershell-no-profile", +]); +type DesktopShellEnvironmentProbe = typeof DesktopShellEnvironmentProbe.Type; + const desktopShellEnvironmentCommandFields = { - command: Schema.String, - args: Schema.Array(Schema.String), + probe: DesktopShellEnvironmentProbe, + executable: Schema.String, + argumentCount: Schema.Number, }; export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass()( @@ -34,7 +43,7 @@ export class DesktopShellEnvironmentCommandError extends Schema.TaggedErrorClass }, ) { override get message(): string { - return `Desktop shell environment command ${this.command} failed.`; + return `Desktop shell environment ${this.probe} probe (${this.executable}) failed.`; } } @@ -46,7 +55,7 @@ export class DesktopShellEnvironmentCommandTimeoutError extends Schema.TaggedErr }, ) { override get message(): string { - return `Desktop shell environment command ${this.command} timed out after ${this.timeoutMs}ms.`; + return `Desktop shell environment ${this.probe} probe (${this.executable}) timed out after ${this.timeoutMs}ms.`; } } @@ -157,6 +166,8 @@ const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; +const executableName = (command: string): string => command.split(/[\\/]/u).at(-1) ?? command; + const logShellEnvironmentCommandError = ( error: DesktopShellEnvironmentCommandError | DesktopShellEnvironmentCommandTimeoutError, ) => @@ -215,6 +226,7 @@ const extractEnvironment = (output: string, names: ReadonlyArray): Envir }; const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(function* (input: { + readonly probe: DesktopShellEnvironmentProbe; readonly command: string; readonly args: ReadonlyArray; readonly timeout: Duration.Duration; @@ -236,8 +248,9 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( Effect.mapError( (cause) => new DesktopShellEnvironmentCommandError({ - command: input.command, - args: input.args, + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, cause, }), ), @@ -252,8 +265,9 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( } const error = new DesktopShellEnvironmentCommandTimeoutError({ - command: input.command, - args: input.args, + probe: input.probe, + executable: executableName(input.command), + argumentCount: input.args.length, timeoutMs: Duration.toMillis(input.timeout), }); yield* logShellEnvironmentCommandError(error); @@ -267,12 +281,14 @@ const readLoginShellEnvironment = ( names.length === 0 ? Effect.succeed({}) : runCommandOutput({ + probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); const readLaunchctlPath = runCommandOutput({ + probe: "launchctl-path", command: "/bin/launchctl", args: ["getenv", "PATH"], timeout: LAUNCHCTL_TIMEOUT, @@ -295,6 +311,7 @@ const readWindowsEnvironment = Effect.fn("desktop.shellEnvironment.readWindowsEn for (const command of WINDOWS_SHELL_CANDIDATES) { const output = yield* runCommandOutput({ + probe: options.loadProfile ? "powershell-profile" : "powershell-no-profile", command, args, timeout: LOGIN_SHELL_TIMEOUT,