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;
}