Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<A, E, R>(
env: NodeJS.ProcessEnv,
effect: Effect.Effect<A, E, R>,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> =>
Option.fromNullishOr(value).pipe(
Expand All @@ -92,6 +93,13 @@ const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";"
const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
trimNonEmpty(env.PATH ?? env.Path ?? env.path);

const loginShellProbeEnv = (
env: NodeJS.ProcessEnv,
): Readonly<Record<string, string | undefined>> => ({
[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;
Expand Down Expand Up @@ -231,11 +239,19 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
readonly args: ReadonlyArray<string>;
readonly timeout: Duration.Duration;
readonly shell?: boolean;
readonly env?: Readonly<Record<string, string | undefined>>;
readonly extendEnv?: boolean;
}): Effect.fn.Return<string, never, ChildProcessSpawner.ChildProcessSpawner> {
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",
Expand Down Expand Up @@ -277,13 +293,16 @@ const runCommandOutput = Effect.fn("desktop.shellEnvironment.runCommandOutput")(
const readLoginShellEnvironment = (
shell: string,
names: ReadonlyArray<string>,
env: NodeJS.ProcessEnv,
): Effect.Effect<EnvironmentPatch, never, ChildProcessSpawner.ChildProcessSpawner> =>
names.length === 0
? Effect.succeed({})
: runCommandOutput({
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)));

Expand Down Expand Up @@ -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;
}
Expand Down
Loading