From 4343b844871e6fb297cd01c82f182b11b2e4df2d Mon Sep 17 00:00:00 2001 From: Simone <185146821+Lucenx9@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:08:55 +0100 Subject: [PATCH 1/3] fix(shared): preserve whitespace in POSIX PATH directories --- packages/shared/src/shell.test.ts | 31 +++++++++++++++++++++++++++++++ packages/shared/src/shell.ts | 14 +++++++++----- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index d4e831f2af4bd..b4aef52ecada9 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -207,6 +207,9 @@ describe("mergePathEntries", () => { expect( mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"), ).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + expect(mergePathEntries("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe( + "/opt/bin :/opt/bin:/usr/bin", + ); }); }); @@ -467,10 +470,38 @@ describe("mergePathValues", () => { expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe( "/usr/local/bin:/usr/bin:/USR/BIN", ); + expect(mergePathValues("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe( + "/opt/bin :/opt/bin:/usr/bin", + ); }); }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { + it.effect.each(["linux", "darwin"] as const)( + "finds executables in a PATH directory ending in whitespace on %s", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ directory: ".", prefix: "t3-path-" }); + const directory = `${cwd}/bin \u00a0`; + yield* fs.makeDirectory(directory); + const executable = `${directory}/audit-command`; + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + const env = { PATH: directory }; + + expect( + yield* resolveCommandPath("audit-command", { env }).pipe( + Effect.provideService(HostProcess.Platform, platform), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ).toBe(path.join(directory, "audit-command")); + const resolveExecutable = yield* SpawnExecutableResolution; + expect(resolveExecutable("audit-command", platform, env)).toBe(executable); + }), + ); + const recordProbes = (env: NodeJS.ProcessEnv) => Effect.gen(function* () { const probed: Array = []; diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 8a0e0be51922a..d16608e2fd4ae 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -106,7 +106,7 @@ function resolveSpawnExecutableWithNode( } for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) { - const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim()); + const normalizedPathEntry = stripWrappingQuotes(trimPathEntry(pathEntry, platform)); if (normalizedPathEntry.length === 0) continue; for (const candidate of candidates) { const candidatePath = path.join(normalizedPathEntry, candidate); @@ -188,7 +188,7 @@ export function mergePathEntries( for (const pathValue of [preferredPath, inheritedPath]) { if (!pathValue) continue; for (const entry of pathValue.split(delimiter)) { - const trimmedEntry = entry.trim(); + const trimmedEntry = trimPathEntry(entry, platform); if (!trimmedEntry || seen.has(trimmedEntry)) { continue; } @@ -409,12 +409,16 @@ function stripWrappingQuotes(value: string): string { return value.replace(/^"+|"+$/g, ""); } +function trimPathEntry(entry: string, platform: NodeJS.Platform): string { + return platform === "win32" ? entry.trim() : entry; +} + function pathDelimiterForPlatform(platform: NodeJS.Platform): string { return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; } function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { - const normalized = stripWrappingQuotes(entry.trim()); + const normalized = stripWrappingQuotes(trimPathEntry(entry, platform)); return platform === "win32" ? normalized.toLowerCase() : normalized; } @@ -435,7 +439,7 @@ export function mergePathValues( if (!rawValue) continue; for (const entry of rawValue.split(delimiter)) { - const sanitized = sanitizePathEntry(entry.trim(), platform); + const sanitized = sanitizePathEntry(trimPathEntry(entry, platform), platform); if (sanitized.length === 0) continue; const normalized = normalizePathEntryForComparison(sanitized, platform); @@ -649,7 +653,7 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat const pathEntries: string[] = []; const seenPathEntries = new Set(); for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) { - const pathEntry = stripWrappingQuotes(entry.trim()); + const pathEntry = stripWrappingQuotes(trimPathEntry(entry, platform)); if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue; seenPathEntries.add(pathEntry); From 49ad5347d266e974f967f5cde95d122fed6b4a32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 16:29:11 +0000 Subject: [PATCH 2/3] test(shared): cover the whitespace PATH scan once linux and darwin take the same non-Windows branch. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/shell.test.ts | 42 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index ca53e97317ef1..1c2a2c8cf4f25 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -521,29 +521,27 @@ describe("mergePathValues", () => { }); effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { - it.effect.each(["linux", "darwin"] as const)( - "finds executables in a PATH directory ending in whitespace on %s", - (platform) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cwd = yield* fs.makeTempDirectoryScoped({ directory: ".", prefix: "t3-path-" }); - const directory = `${cwd}/bin \u00a0`; - yield* fs.makeDirectory(directory); - const executable = `${directory}/audit-command`; - yield* fs.writeFileString(executable, "#!/bin/sh\n"); - yield* fs.chmod(executable, 0o755); - const env = { PATH: directory }; + it.effect("finds executables in a POSIX PATH directory ending in whitespace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ directory: ".", prefix: "t3-path-" }); + const directory = `${cwd}/bin \u00a0`; + yield* fs.makeDirectory(directory); + const executable = `${directory}/audit-command`; + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + const env = { PATH: directory }; - expect( - yield* resolveCommandPath("audit-command", { env }).pipe( - Effect.provideService(HostProcess.Platform, platform), - Effect.provideService(CommandResolutionCache, new Map()), - ), - ).toBe(path.join(directory, "audit-command")); - const resolveExecutable = yield* SpawnExecutableResolution; - expect(resolveExecutable("audit-command", platform, env)).toBe(executable); - }), + expect( + yield* resolveCommandPath("audit-command", { env }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(CommandResolutionCache, new Map()), + ), + ).toBe(path.join(directory, "audit-command")); + const resolveExecutable = yield* SpawnExecutableResolution; + expect(resolveExecutable("audit-command", "linux", env)).toBe(executable); + }), ); const recordProbes = (env: NodeJS.ProcessEnv) => From d27b2daf708180209a903bda0680df394d4e7f49 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 16:50:15 +0000 Subject: [PATCH 3/3] fix(desktop): keep whitespace in POSIX PATH directories Desktop merged PATH with its own copy of the merge that trimmed every entry, and trimmed whole PATH values before that, so the server received an already trimmed PATH. Merge through the shared mergePathValues, which trims only on Windows, and pass POSIX PATH values through untrimmed. Co-Authored-By: Claude Opus 5.5 --- .../src/shell/DesktopShellEnvironment.test.ts | 6 +-- .../src/shell/DesktopShellEnvironment.ts | 46 ++++++------------- 2 files changed, 16 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 88c45f37cc467..3486c296da516 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -486,7 +486,7 @@ describe("DesktopShellEnvironment", () => { Effect.gen(function* () { const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", - PATH: "/Users/test/.local/bin:/usr/bin", + PATH: " /Users/test/.local/bin:/usr/bin", }; const commands: ChildProcess.Command[] = []; @@ -496,7 +496,7 @@ describe("DesktopShellEnvironment", () => { handler: (command) => { commands.push(command); return envOutput({ - PATH: "/opt/homebrew/bin:/usr/bin", + PATH: "/opt/homebrew/bin:/usr/bin:/opt/bin ", SSH_AUTH_SOCK: "/tmp/secretive.sock", HOMEBREW_PREFIX: "/opt/homebrew", }); @@ -505,7 +505,7 @@ describe("DesktopShellEnvironment", () => { assert.equal(commands.length, 1); assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh"); - assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin"); + assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/opt/bin : /Users/test/.local/bin"); assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock"); assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew"); assert.isTrue(prepared); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 299b0902b57a5..5389d94c1e8e8 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -3,6 +3,7 @@ import { applyShellEnvironment, buildEnvironmentCaptureCommand, listLoginShellCandidates, + mergePathValues, readUserLoginShell, setWindowsPath, } from "@t3tools/shared/shell"; @@ -131,10 +132,12 @@ const trimNonEmpty = (value: string | null | undefined): Option.Option = Option.filter((entry) => entry.length > 0), ); -const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":"); +// POSIX directory names may begin or end with whitespace; mergePathValues trims only on Windows. +const nonEmptyPath = (value: string | null | undefined): Option.Option => + Option.fromNullishOr(value).pipe(Option.filter((entry) => entry.length > 0)); const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option => - trimNonEmpty(env.PATH ?? env.Path ?? env.path); + nonEmptyPath(env.PATH ?? env.Path ?? env.path); const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, ""); @@ -153,39 +156,16 @@ const linuxRuntimeDirCandidates = ( return candidates.filter((candidate) => candidate.length > 0); }; -const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { - const normalized = entry.trim().replace(/^"+|"+$/g, ""); - return platform === "win32" ? normalized.toLowerCase() : normalized; -}; - -const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) => - platform === "win32" ? entry.replaceAll('"', "") : entry; - const mergePaths = ( platform: NodeJS.Platform, values: ReadonlyArray>, -): Option.Option => { - const delimiter = pathDelimiter(platform); - const entries: string[] = []; - const seen = new Set(); - - for (const value of values) { - if (Option.isNone(value)) continue; - - for (const entry of value.value.split(delimiter)) { - const sanitized = sanitizePathEntry(entry.trim(), platform); - if (sanitized.length === 0) continue; - - const key = pathComparisonKey(sanitized, platform); - if (key.length === 0 || seen.has(key)) continue; - - seen.add(key); - entries.push(sanitized); - } - } - - return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none(); -}; +): Option.Option => + Option.fromNullishOr( + values.reduce( + (merged, value) => mergePathValues(merged, Option.getOrUndefined(value), platform), + undefined, + ), + ); const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ ...trimNonEmpty(env.APPDATA).pipe( @@ -484,7 +464,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix ? yield* readLaunchctlPath(config.env) : Option.none(); const mergedPath = mergePaths(config.platform, [ - trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), + nonEmptyPath(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)), readEnvPath(config.env), ]);