diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 8bf2072f3d..1c33f58844 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -6,6 +6,7 @@ import { BaseTerminal } from "./BaseTerminal" import { TerminalProcess } from "./TerminalProcess" import { ShellIntegrationManager } from "./ShellIntegrationManager" import { mergePromise } from "./mergePromise" +import { getAutomationShell } from "../../utils/shell" export class Terminal extends BaseTerminal { public terminal: vscode.Terminal @@ -17,7 +18,19 @@ export class Terminal extends BaseTerminal { const env = Terminal.getEnv() const iconPath = new vscode.ThemeIcon("rocket") - this.terminal = terminal ?? vscode.window.createTerminal({ cwd, name: "Roo Code", iconPath, env }) + + // Try to get the automation shell first, fall back to default behavior if not configured + const shellPath = getAutomationShell() || undefined + + const terminalOptions: vscode.TerminalOptions = { + cwd, + name: "Roo Code", + iconPath, + env, + shellPath, + } + + this.terminal = terminal ?? vscode.window.createTerminal(terminalOptions) if (Terminal.getTerminalZdotdir()) { ShellIntegrationManager.terminalTmpDirs.set(id, env.ZDOTDIR) diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index d3912caf47..4f6ea5fbec 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode" import { Terminal } from "../Terminal" import { TerminalRegistry } from "../TerminalRegistry" +import * as shellUtils from "../../../utils/shell" const PAGER = process.platform === "win32" ? "" : "cat" @@ -10,6 +11,10 @@ vi.mock("execa", () => ({ execa: vi.fn(), })) +vi.mock("../../../utils/shell", () => ({ + getAutomationShell: vi.fn(() => null), +})) + describe("TerminalRegistry", () => { let mockCreateTerminal: any @@ -118,5 +123,80 @@ describe("TerminalRegistry", () => { Terminal.setTerminalZshP10k(false) } }) + + it("uses automation profile shell when configured", () => { + // Mock getAutomationShell to return a specific shell path + const getAutomationShellMock = shellUtils.getAutomationShell as any + getAutomationShellMock.mockReturnValue("/bin/bash") + + try { + TerminalRegistry.createTerminal("/test/path", "vscode") + + expect(mockCreateTerminal).toHaveBeenCalledWith({ + cwd: "/test/path", + name: "Roo Code", + iconPath: expect.any(Object), + env: { + PAGER, + VTE_VERSION: "0", + PROMPT_EOL_MARK: "", + }, + shellPath: "/bin/bash", + }) + } finally { + // Reset mock + getAutomationShellMock.mockReturnValue(null) + } + }) + + it("does not set shellPath when automation profile is not configured", () => { + // Mock getAutomationShell to return null (no automation profile) + const getAutomationShellMock = shellUtils.getAutomationShell as any + getAutomationShellMock.mockReturnValue(null) + + TerminalRegistry.createTerminal("/test/path", "vscode") + + expect(mockCreateTerminal).toHaveBeenCalledWith({ + cwd: "/test/path", + name: "Roo Code", + iconPath: expect.any(Object), + env: { + PAGER, + VTE_VERSION: "0", + PROMPT_EOL_MARK: "", + }, + shellPath: undefined, + }) + }) + + it("uses Windows automation profile when configured", () => { + // Mock for Windows platform + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "win32", configurable: true }) + + // Mock getAutomationShell to return PowerShell 7 path + const getAutomationShellMock = shellUtils.getAutomationShell as any + getAutomationShellMock.mockReturnValue("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + + try { + TerminalRegistry.createTerminal("/test/path", "vscode") + + expect(mockCreateTerminal).toHaveBeenCalledWith({ + cwd: "/test/path", + name: "Roo Code", + iconPath: expect.any(Object), + env: { + PAGER: "", + VTE_VERSION: "0", + PROMPT_EOL_MARK: "", + }, + shellPath: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + }) + } finally { + // Restore platform and mock + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }) + getAutomationShellMock.mockReturnValue(null) + } + }) }) }) diff --git a/src/utils/__tests__/shell.spec.ts b/src/utils/__tests__/shell.spec.ts index 8f370e4d7f..c50f67eceb 100644 --- a/src/utils/__tests__/shell.spec.ts +++ b/src/utils/__tests__/shell.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import * as vscode from "vscode" import { userInfo } from "os" -import { getShell } from "../shell" +import { getShell, getAutomationShell } from "../shell" // Mock vscode module vi.mock("vscode", () => ({ @@ -485,4 +485,213 @@ describe("Shell Detection Tests", () => { expect(result).toBe("/bin/bash") // Should fall back to safe default }) }) + + // -------------------------------------------------------------------------- + // Automation Shell Detection Tests + // -------------------------------------------------------------------------- + describe("Automation Shell Detection", () => { + describe("Windows Automation Shell", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "win32" }) + }) + + it("uses automation profile path when configured", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.windows") { + return { path: "C:\\Program Files\\PowerShell\\7\\pwsh.exe" } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("C:\\Program Files\\PowerShell\\7\\pwsh.exe") + }) + + it("handles array path in automation profile", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.windows") { + return { path: ["C:\\Program Files\\Git\\bin\\bash.exe", "bash.exe"] } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("C:\\Program Files\\Git\\bin\\bash.exe") + }) + + it("returns null when no automation profile is configured", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.windows") { + return null + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + + it("returns null when automation profile has no path", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.windows") { + return { source: "PowerShell" } // No path property + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + + it("returns null when automation profile path is not in allowlist", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.windows") { + return { path: "C:\\malicious\\shell.exe" } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + }) + + describe("macOS Automation Shell", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "darwin" }) + }) + + it("uses automation profile path when configured", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.osx") { + return { path: "/bin/bash" } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("/bin/bash") + }) + + it("handles array path in automation profile", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.osx") { + return { path: ["/usr/local/bin/bash", "/bin/bash"] } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("/usr/local/bin/bash") + }) + + it("returns null when no automation profile is configured", () => { + const mockConfig = { + get: vi.fn(() => undefined), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + }) + + describe("Linux Automation Shell", () => { + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "linux" }) + }) + + it("uses automation profile path when configured", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.linux") { + return { path: "/bin/bash" } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("/bin/bash") + }) + + it("handles array path in automation profile", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.linux") { + return { path: ["/usr/bin/zsh", "/bin/zsh"] } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBe("/usr/bin/zsh") + }) + + it("returns null when automation profile path is empty array", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.linux") { + return { path: [] } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + + it("validates automation shell against allowlist", () => { + const mockConfig = { + get: vi.fn((key: string) => { + if (key === "automationProfile.linux") { + return { path: "/usr/bin/evil-shell" } + } + return undefined + }), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + }) + + describe("Unknown Platform Automation Shell", () => { + it("returns null for unknown platforms", () => { + Object.defineProperty(process, "platform", { value: "sunos" }) + const mockConfig = { + get: vi.fn(() => undefined), + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(mockConfig as any) + + expect(getAutomationShell()).toBeNull() + }) + }) + + describe("Error Handling in Automation Shell", () => { + it("handles configuration errors gracefully", () => { + Object.defineProperty(process, "platform", { value: "win32" }) + vscode.workspace.getConfiguration = () => { + throw new Error("Configuration error") + } + + expect(getAutomationShell()).toBeNull() + }) + }) + }) }) diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 45253c31b0..caec9cd786 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -168,6 +168,37 @@ function getLinuxTerminalConfig() { } } +// Automation profile helpers +function getWindowsAutomationProfile(): WindowsTerminalProfile | null { + try { + const config = vscode.workspace.getConfiguration("terminal.integrated") + const automationProfile = config.get("automationProfile.windows") + return automationProfile || null + } catch { + return null + } +} + +function getMacAutomationProfile(): MacTerminalProfile | null { + try { + const config = vscode.workspace.getConfiguration("terminal.integrated") + const automationProfile = config.get("automationProfile.osx") + return automationProfile || null + } catch { + return null + } +} + +function getLinuxAutomationProfile(): LinuxTerminalProfile | null { + try { + const config = vscode.workspace.getConfiguration("terminal.integrated") + const automationProfile = config.get("automationProfile.linux") + return automationProfile || null + } catch { + return null + } +} + // ----------------------------------------------------- // 2) Platform-Specific VS Code Shell Retrieval // ----------------------------------------------------- @@ -368,3 +399,32 @@ export function getShell(): string { return shell } + +/** + * Gets the automation shell path for the current platform. + * This is used for automation tools and should respect the automationProfile settings. + * Falls back to the default shell if no automation profile is configured. + */ +export function getAutomationShell(): string | null { + let automationProfile: WindowsTerminalProfile | MacTerminalProfile | LinuxTerminalProfile | null = null + + // Get the automation profile for the current platform + if (process.platform === "win32") { + automationProfile = getWindowsAutomationProfile() + } else if (process.platform === "darwin") { + automationProfile = getMacAutomationProfile() + } else if (process.platform === "linux") { + automationProfile = getLinuxAutomationProfile() + } + + // If we have an automation profile with a path, use it + if (automationProfile?.path) { + const shellPath = normalizeShellPath(automationProfile.path) + if (shellPath && isShellAllowed(shellPath)) { + return shellPath + } + } + + // Fall back to the default shell + return null +}