diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 7ec0ab80ae7..8d0bc630ed0 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -45,6 +45,11 @@ function makeProcess(output: string): ChildProcessSpawner.ChildProcessHandle { }); } +function standardCommand(command: ChildProcess.Command): ChildProcess.StandardCommand { + assert.equal(command._tag, "StandardCommand"); + return command as ChildProcess.StandardCommand; +} + function withProcessEnv( env: NodeJS.ProcessEnv, effect: Effect.Effect, @@ -173,6 +178,60 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("marks POSIX login shell probes and supplies TERM=dumb when TERM is absent", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "linux", + handler: (command) => { + commands.push(command); + return envOutput({ PATH: "/usr/local/bin:/usr/bin" }); + }, + }); + + const command = standardCommand(commands[0] as ChildProcess.Command); + assert.deepInclude(command.args, "-ilc"); + assert.equal(command.options.extendEnv, true); + assert.deepEqual(command.options.env, { + T3CODE_RESOLVING_ENVIRONMENT: "1", + TERM: "dumb", + }); + }), + ); + + it.effect("preserves inherited TERM for POSIX login shell probes", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + SHELL: "/bin/zsh", + PATH: "/usr/bin", + TERM: "xterm-256color", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "darwin", + handler: (command) => { + commands.push(command); + return envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }); + }, + }); + + const command = standardCommand(commands[0] as ChildProcess.Command); + assert.equal(command.options.extendEnv, true); + assert.deepEqual(command.options.env, { + T3CODE_RESOLVING_ENVIRONMENT: "1", + TERM: "xterm-256color", + }); + }), + ); + it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { @@ -243,6 +302,31 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("does not add POSIX probe env to Windows PowerShell probes", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + }; + const commands: ChildProcess.Command[] = []; + + yield* runShellEnvironment({ + env, + platform: "win32", + handler: (command) => { + commands.push(command); + return envOutput({ PATH: "C:\\Windows\\System32" }); + }, + }); + + assert.isAtLeast(commands.length, 1); + for (const command of commands) { + const standard = standardCommand(command); + assert.isUndefined(standard.options.env); + assert.isUndefined(standard.options.extendEnv); + } + }), + ); + it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 8219f18b7a5..0528c8a92a6 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -80,6 +80,7 @@ const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; const LOGIN_SHELL_TIMEOUT = Duration.seconds(5); const LAUNCHCTL_TIMEOUT = Duration.seconds(2); const PROCESS_TERMINATE_GRACE = Duration.seconds(1); +const ENVIRONMENT_RESOLUTION_MARKER = "T3CODE_RESOLVING_ENVIRONMENT"; const trimNonEmpty = (value: string | null | undefined): Option.Option => Option.fromNullishOr(value).pipe( @@ -92,6 +93,13 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => trimNonEmpty(env.PATH ?? env.Path ?? env.path); +const loginShellProbeEnv = ( + env: NodeJS.ProcessEnv, +): Readonly> => ({ + [ENVIRONMENT_RESOLUTION_MARKER]: "1", + TERM: env.TERM ?? "dumb", +}); + const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; @@ -231,11 +239,19 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( readonly args: ReadonlyArray; readonly timeout: Duration.Duration; readonly shell?: boolean; + readonly env?: Readonly>; + readonly extendEnv?: boolean; }): Effect.fn.Return { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const output = yield* spawner .string( ChildProcess.make(input.command, input.args, { + ...(input.env === undefined + ? {} + : { + env: input.env, + extendEnv: input.extendEnv ?? false, + }), shell: input.shell ?? false, stdin: "ignore", stdout: "pipe", @@ -277,6 +293,7 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")( const readLoginShellEnvironment = ( shell: string, names: ReadonlyArray, + env: NodeJS.ProcessEnv, ): Effect.Effect => names.length === 0 ? Effect.succeed({}) @@ -284,6 +301,8 @@ const readLoginShellEnvironment = ( probe: "login-shell", command: shell, args: ["-ilc", capturePosixEnvironmentCommand(names)], + env: loginShellProbeEnv(env), + extendEnv: true, timeout: LOGIN_SHELL_TIMEOUT, }).pipe(Effect.map((output) => extractEnvironment(output, names))); @@ -362,7 +381,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix for (const shell of listLoginShellCandidates(config)) { Object.assign( shellEnvironment, - yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES), + yield* readLoginShellEnvironment(shell, LOGIN_SHELL_ENV_NAMES, config.env), ); if (shellEnvironment.PATH) break; }