diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9ccd8512a..62ada894f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -806,6 +806,9 @@ importers: web-tree-sitter: specifier: ^0.25.6 version: 0.25.6 + which: + specifier: ^5.0.0 + version: 5.0.0 workerpool: specifier: ^9.2.0 version: 9.2.0 @@ -876,6 +879,9 @@ importers: '@types/vscode': specifier: ^1.84.0 version: 1.100.0 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 '@vscode/test-electron': specifier: ^2.5.2 version: 2.5.2 @@ -4262,6 +4268,9 @@ packages: '@types/vscode@1.103.0': resolution: {integrity: sha512-o4hanZAQdNfsKecexq9L3eHICd0AAvdbLk6hA60UzGXbGH/q8b/9xv2RgR7vV3ZcHuyKVq7b37IGd/+gM4Tu+Q==} + '@types/which@3.0.4': + resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -13821,6 +13830,8 @@ snapshots: '@types/vscode@1.103.0': {} + '@types/which@3.0.4': {} + '@types/ws@8.18.1': dependencies: '@types/node': 24.2.1 @@ -13996,7 +14007,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: diff --git a/src/core/prompts/sections/system-info.ts b/src/core/prompts/sections/system-info.ts index 8adc90a160..30ed037d02 100644 --- a/src/core/prompts/sections/system-info.ts +++ b/src/core/prompts/sections/system-info.ts @@ -3,13 +3,14 @@ import osName from "os-name" import { getShell } from "../../../utils/shell" -export function getSystemInfoSection(cwd: string): string { +export async function getSystemInfoSection(cwd: string): Promise { + const shell = await getShell() let details = `==== SYSTEM INFORMATION Operating System: ${osName()} -Default Shell: ${getShell()} +Default Shell: ${shell} Home Directory: ${os.homedir().toPosix()} Current Workspace Directory: ${cwd.toPosix()} diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index 3cc327c815..b51dd331da 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -120,7 +120,7 @@ ${modesSection} ${getRulesSection(cwd, supportsComputerUse, effectiveDiffStrategy, codeIndexManager)} -${getSystemInfoSection(cwd)} +${await getSystemInfoSection(cwd)} ${getObjectiveSection(codeIndexManager, experiments)} diff --git a/src/package.json b/src/package.json index cea265de20..c0b73ee718 100644 --- a/src/package.json +++ b/src/package.json @@ -497,6 +497,7 @@ "uuid": "^11.1.0", "vscode-material-icons": "^0.1.1", "web-tree-sitter": "^0.25.6", + "which": "^5.0.0", "workerpool": "^9.2.0", "yaml": "^2.8.0", "zod": "^3.25.61" @@ -522,6 +523,7 @@ "@types/tmp": "^0.2.6", "@types/turndown": "^5.0.5", "@types/vscode": "^1.84.0", + "@types/which": "^3.0.4", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", "esbuild": "^0.25.0", diff --git a/src/utils/__tests__/shell.spec.ts b/src/utils/__tests__/shell.spec.ts index 733c9dd78a..ba3662180c 100644 --- a/src/utils/__tests__/shell.spec.ts +++ b/src/utils/__tests__/shell.spec.ts @@ -7,6 +7,13 @@ vi.mock("os", () => ({ userInfo: vi.fn(() => ({ shell: null })), })) +// Mock the which module +vi.mock("which", () => ({ + default: vi.fn(), +})) + +import which from "which" + describe("Shell Detection Tests", () => { let originalPlatform: string let originalEnv: NodeJS.ProcessEnv @@ -40,6 +47,12 @@ describe("Shell Detection Tests", () => { // Reset userInfo mock to default vi.mocked(userInfo).mockReturnValue({ shell: null } as any) + + // Mock which to always resolve paths successfully for tests + vi.mocked(which).mockImplementation(async (cmd: string) => { + // Return the command as-is to simulate successful resolution + return cmd + }) }) afterEach(() => { @@ -58,66 +71,66 @@ describe("Shell Detection Tests", () => { Object.defineProperty(process, "platform", { value: "win32" }) }) - it("uses explicit PowerShell 7 path from VS Code config (profile path)", () => { + it("uses explicit PowerShell 7 path from VS Code config (profile path)", async () => { mockVsCodeConfig("windows", "PowerShell", { PowerShell: { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" }, }) - expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + expect(await getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) - it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", () => { + it("uses PowerShell 7 path if source is 'PowerShell' but no explicit path", async () => { mockVsCodeConfig("windows", "PowerShell", { PowerShell: { source: "PowerShell" }, }) - expect(getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + expect(await getShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") }) - it("falls back to legacy PowerShell if profile includes 'powershell' but no path/source", () => { + it("falls back to legacy PowerShell if profile includes 'powershell' but no path/source", async () => { mockVsCodeConfig("windows", "PowerShell", { PowerShell: {}, }) - expect(getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") + expect(await getShell()).toBe("C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe") }) - it("uses WSL bash when profile indicates WSL source", () => { + it("uses WSL bash when profile indicates WSL source", async () => { mockVsCodeConfig("windows", "WSL", { WSL: { source: "WSL" }, }) - expect(getShell()).toBe("/bin/bash") + expect(await getShell()).toBe("/bin/bash") }) - it("uses WSL bash when profile name includes 'wsl'", () => { + it("uses WSL bash when profile name includes 'wsl'", async () => { mockVsCodeConfig("windows", "Ubuntu WSL", { "Ubuntu WSL": {}, }) - expect(getShell()).toBe("/bin/bash") + expect(await getShell()).toBe("/bin/bash") }) - it("defaults to cmd.exe if no special profile is matched", () => { + it("defaults to cmd.exe if no special profile is matched", async () => { mockVsCodeConfig("windows", "CommandPrompt", { CommandPrompt: {}, }) - expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") + expect(await getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) - it("handles undefined profile gracefully", () => { + it("handles undefined profile gracefully", async () => { // Mock a case where defaultProfileName exists but the profile doesn't mockVsCodeConfig("windows", "NonexistentProfile", {}) - expect(getShell()).toBe("C:\\Windows\\System32\\cmd.exe") + expect(await getShell()).toBe("C:\\Windows\\System32\\cmd.exe") }) - it("respects userInfo() if no VS Code config is available", () => { + it("respects userInfo() if no VS Code config is available", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any vi.mocked(userInfo).mockReturnValue({ shell: "C:\\Custom\\PowerShell.exe" } as any) - expect(getShell()).toBe("C:\\Custom\\PowerShell.exe") + expect(await getShell()).toBe("C:\\Custom\\PowerShell.exe") }) - it("respects an odd COMSPEC if no userInfo shell is available", () => { + it("respects an odd COMSPEC if no userInfo shell is available", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any process.env.COMSPEC = "D:\\CustomCmd\\cmd.exe" - expect(getShell()).toBe("D:\\CustomCmd\\cmd.exe") + expect(await getShell()).toBe("D:\\CustomCmd\\cmd.exe") }) }) @@ -129,28 +142,28 @@ describe("Shell Detection Tests", () => { Object.defineProperty(process, "platform", { value: "darwin" }) }) - it("uses VS Code profile path if available", () => { + it("uses VS Code profile path if available", async () => { mockVsCodeConfig("osx", "MyCustomShell", { MyCustomShell: { path: "/usr/local/bin/fish" }, }) - expect(getShell()).toBe("/usr/local/bin/fish") + expect(await getShell()).toBe("/usr/local/bin/fish") }) - it("falls back to userInfo().shell if no VS Code config is available", () => { + it("falls back to userInfo().shell if no VS Code config is available", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any vi.mocked(userInfo).mockReturnValue({ shell: "/opt/homebrew/bin/zsh" } as any) - expect(getShell()).toBe("/opt/homebrew/bin/zsh") + expect(await getShell()).toBe("/opt/homebrew/bin/zsh") }) - it("falls back to SHELL env var if no userInfo shell is found", () => { + it("falls back to SHELL env var if no userInfo shell is found", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any process.env.SHELL = "/usr/local/bin/zsh" - expect(getShell()).toBe("/usr/local/bin/zsh") + expect(await getShell()).toBe("/usr/local/bin/zsh") }) - it("falls back to /bin/zsh if no config, userInfo, or env variable is set", () => { + it("falls back to /bin/zsh if no config, userInfo, or env variable is set", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any - expect(getShell()).toBe("/bin/zsh") + expect(await getShell()).toBe("/bin/zsh") }) }) @@ -162,28 +175,28 @@ describe("Shell Detection Tests", () => { Object.defineProperty(process, "platform", { value: "linux" }) }) - it("uses VS Code profile path if available", () => { + it("uses VS Code profile path if available", async () => { mockVsCodeConfig("linux", "CustomProfile", { CustomProfile: { path: "/usr/bin/fish" }, }) - expect(getShell()).toBe("/usr/bin/fish") + expect(await getShell()).toBe("/usr/bin/fish") }) - it("falls back to userInfo().shell if no VS Code config is available", () => { + it("falls back to userInfo().shell if no VS Code config is available", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any vi.mocked(userInfo).mockReturnValue({ shell: "/usr/bin/zsh" } as any) - expect(getShell()).toBe("/usr/bin/zsh") + expect(await getShell()).toBe("/usr/bin/zsh") }) - it("falls back to SHELL env var if no userInfo shell is found", () => { + it("falls back to SHELL env var if no userInfo shell is found", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any process.env.SHELL = "/usr/bin/fish" - expect(getShell()).toBe("/usr/bin/fish") + expect(await getShell()).toBe("/usr/bin/fish") }) - it("falls back to /bin/bash if nothing is set", () => { + it("falls back to /bin/bash if nothing is set", async () => { vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any - expect(getShell()).toBe("/bin/bash") + expect(await getShell()).toBe("/bin/bash") }) }) @@ -191,32 +204,32 @@ describe("Shell Detection Tests", () => { // Unknown Platform & Error Handling // -------------------------------------------------------------------------- describe("Unknown Platform / Error Handling", () => { - it("falls back to /bin/sh for unknown platforms", () => { + it("falls back to /bin/sh for unknown platforms", async () => { Object.defineProperty(process, "platform", { value: "sunos" }) vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any - expect(getShell()).toBe("/bin/sh") + expect(await getShell()).toBe("/bin/sh") }) - it("handles VS Code config errors gracefully, falling back to userInfo shell if present", () => { + it("handles VS Code config errors gracefully, falling back to userInfo shell if present", async () => { Object.defineProperty(process, "platform", { value: "linux" }) vscode.workspace.getConfiguration = () => { throw new Error("Configuration error") } vi.mocked(userInfo).mockReturnValue({ shell: "/bin/bash" } as any) - expect(getShell()).toBe("/bin/bash") + expect(await getShell()).toBe("/bin/bash") }) - it("handles userInfo errors gracefully, falling back to environment variable if present", () => { + it("handles userInfo errors gracefully, falling back to environment variable if present", async () => { Object.defineProperty(process, "platform", { value: "darwin" }) vscode.workspace.getConfiguration = () => ({ get: () => undefined }) as any vi.mocked(userInfo).mockImplementation(() => { throw new Error("userInfo error") }) process.env.SHELL = "/bin/zsh" - expect(getShell()).toBe("/bin/zsh") + expect(await getShell()).toBe("/bin/zsh") }) - it("falls back fully to default shell paths if everything fails", () => { + it("falls back fully to default shell paths if everything fails", async () => { Object.defineProperty(process, "platform", { value: "linux" }) vscode.workspace.getConfiguration = () => { throw new Error("Configuration error") @@ -225,7 +238,7 @@ describe("Shell Detection Tests", () => { throw new Error("userInfo error") }) delete process.env.SHELL - expect(getShell()).toBe("/bin/bash") + expect(await getShell()).toBe("/bin/bash") }) }) }) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 22a916f02c..531942aa9e 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import { userInfo } from "os" +import which from "which" const SHELL_PATHS = { // Windows paths @@ -20,6 +21,39 @@ const SHELL_PATHS = { FALLBACK: "/bin/sh", } as const +const shellPathCache = new Map() +const CACHE_TTL = 5 * 60 * 1000 + +async function validateShellPath(shellPath: string): Promise { + try { + const cached = shellPathCache.get(shellPath) + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.path + } + + const resolvedPath = await which(shellPath, { nothrow: true }) + + if (resolvedPath) { + shellPathCache.set(shellPath, { + path: resolvedPath, + timestamp: Date.now(), + }) + return resolvedPath + } + + return null + } catch { + return null + } +} + +/** + * Clears the shell path cache (useful for testing) + */ +export function clearShellPathCache(): void { + shellPathCache.clear() +} + interface MacTerminalProfile { path?: string } @@ -182,46 +216,59 @@ function getShellFromEnv(): string | null { // 4) Publicly Exposed Shell Getter // ----------------------------------------------------- -export function getShell(): string { +export async function getShell(): Promise { // 1. Check VS Code config first. if (process.platform === "win32") { // Special logic for Windows const windowsShell = getWindowsShellFromVSCode() if (windowsShell) { - return windowsShell + const validatedPath = await validateShellPath(windowsShell) + if (validatedPath) { + return validatedPath + } } } else if (process.platform === "darwin") { // macOS from VS Code const macShell = getMacShellFromVSCode() if (macShell) { - return macShell + const validatedPath = await validateShellPath(macShell) + if (validatedPath) { + return validatedPath + } } } else if (process.platform === "linux") { // Linux from VS Code const linuxShell = getLinuxShellFromVSCode() if (linuxShell) { - return linuxShell + const validatedPath = await validateShellPath(linuxShell) + if (validatedPath) { + return validatedPath + } } } // 2. If no shell from VS Code, try userInfo() const userInfoShell = getShellFromUserInfo() if (userInfoShell) { - return userInfoShell + const validatedPath = await validateShellPath(userInfoShell) + if (validatedPath) { + return validatedPath + } } // 3. If still nothing, try environment variable const envShell = getShellFromEnv() if (envShell) { - return envShell + const validatedPath = await validateShellPath(envShell) + if (validatedPath) { + return validatedPath + } } - // 4. Finally, fall back to a default + // 4. Finally, fall back to platform-specific defaults if (process.platform === "win32") { - // On Windows, if we got here, we have no config, no COMSPEC, and one very messed up operating system. - // Use CMD as a last resort return SHELL_PATHS.CMD + } else { + return SHELL_PATHS.FALLBACK } - // On macOS/Linux, fallback to a POSIX shell - This is the behavior of our old shell detection method. - return SHELL_PATHS.FALLBACK }