From d8111d6a933d50c26ca34e531429d10d0641d83b Mon Sep 17 00:00:00 2001 From: xcloudx01 Date: Fri, 18 Sep 2026 21:57:38 +1000 Subject: [PATCH 1/3] fix(terminal): prevent inline terminal cmd.exe fallback on Windows BaseTerminalProcess.execaOptions previously passed shell: BaseTerminal.getExecaShellPath() || true, so an unset execaShellPath fell back to shell:true. On that branch the shell process becomes a bare cmd.exe instead of the resolved PowerShell/Zoo profile, causing the inline terminal to silently downgrade to Windows Command Prompt. Change the fallback to ?? getShell(), which resolves through VS Code profile config -> Zoo override -> userInfo -> env -> allowlisted default (never shell:true). Explicit execaShellPath still wins verbatim, and a deliberately selected cmd.exe profile is preserved via getShell(). Adds a cross-path regression suite covering explicit-win, unset->getShell(), PowerShell-via-configured-profiles, deliberate-Command-Prompt preservation, and never-shell:true. --- .../terminal/ExecaTerminalProcess.ts | 3 +- .../__tests__/ExecaTerminalProcess.spec.ts | 61 ++++++++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index cde5a1251f..72edefdbbd 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -5,6 +5,7 @@ import process from "process" import type { RooTerminal } from "./types" import { BaseTerminal } from "./BaseTerminal" import { BaseTerminalProcess } from "./BaseTerminalProcess" +import { getShell } from "../../utils/shell" export class ExecaTerminalProcess extends BaseTerminalProcess { private terminalRef: WeakRef @@ -40,7 +41,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { this.isHot = true this.subprocess = execa({ - shell: BaseTerminal.getExecaShellPath() || true, + shell: BaseTerminal.getExecaShellPath() ?? getShell(), cwd: this.terminal.getCurrentWorkingDirectory(), all: true, // Ignore stdin to ensure non-interactive mode and prevent hanging diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 8292875b87..9fd19d067a 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -25,6 +25,7 @@ vitest.mock("ps-tree", () => ({ import { execa } from "execa" import { ExecaTerminalProcess } from "../ExecaTerminalProcess" +import * as shellUtils from "../../../utils/shell" import { BaseTerminal } from "../BaseTerminal" import type { RooTerminal } from "../types" @@ -63,11 +64,13 @@ describe("ExecaTerminalProcess", () => { describe("UTF-8 encoding fix", () => { it("should set LANG and LC_ALL to en_US.UTF-8", async () => { + // Deterministic shell so the assertion focuses solely on LANG/LC_ALL. + vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) expect(execaMock).toHaveBeenCalledWith( expect.objectContaining({ - shell: true, + shell: "/bin/zsh", cwd: "/test/cwd", all: true, env: expect.objectContaining({ @@ -109,15 +112,19 @@ describe("ExecaTerminalProcess", () => { ) }) - it("should fall back to shell=true when execaShellPath is undefined", async () => { + it("when execaShellPath is unset, Execa resolves through getShell() (never shell:true)", async () => { BaseTerminal.setExecaShellPath(undefined) + const resolved = "/resolved/pwsh.exe" + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue(resolved) await terminalProcess.run("echo test") const execaMock = vitest.mocked(execa) + expect(getShellSpy).toHaveBeenCalledTimes(1) expect(execaMock).toHaveBeenCalledWith( expect.objectContaining({ - shell: true, + shell: resolved, }), ) + expect(execaMock).not.toHaveBeenCalledWith(expect.objectContaining({ shell: true })) }) }) @@ -191,4 +198,52 @@ describe("ExecaTerminalProcess", () => { expect(terminalProcess["lastRetrievedIndex"]).toBe(0) }) }) + + describe("cross-path shell invariant (#705 regression)", () => { + // Bridge through unknown: the mock records the raw options object, whose + // declared type under execa's overloads is string|URL, not a plain record. + const capturedShellOption = (): Record => + vitest.mocked(execa).mock.calls[0][0] as unknown as Record + + beforeEach(() => { + BaseTerminal.setExecaShellPath(undefined) + }) + + it("system-prompt resolved shell == Execa execution shell when no explicit execaShellPath", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("/bin/zsh") + }) + + it("keeps the Execa shell equal to getShell() when a Zoo profile override is set", async () => { + BaseTerminal.setExecaShellPath(undefined) + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\pwsh.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toContain("pwsh.exe") + }) + + it("uses PowerShell when VS Code resolves PowerShell and execaShellPath is unset", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("powershell.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("powershell.exe") + }) + + it("preserves a deliberately selected Command Prompt profile when execaShellPath is unset", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("cmd.exe") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).toBe("cmd.exe") + }) + + it("does NOT delegate to shell:true even when getShell() returns an unusual path", async () => { + const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/opt/custom/fish") + await terminalProcess.run("echo test") + expect(getShellSpy).toHaveBeenCalledTimes(1) + expect(capturedShellOption().shell).not.toBe(true) + expect(capturedShellOption().shell).toBe("/opt/custom/fish") + }) + }) }) From 534e9ef1b2ba48cf1102f9afd4436a8a52999229 Mon Sep 17 00:00:00 2001 From: xcloudx01 Date: Sat, 19 Sep 2026 23:14:03 +1000 Subject: [PATCH 2/3] Fix empty execa shell path fallback Use a truthy fallback so an empty persisted execaShellPath resolves through getShell() instead of being passed through as an empty shell value. --- src/integrations/terminal/ExecaTerminalProcess.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 72edefdbbd..a37646f81d 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -41,7 +41,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { this.isHot = true this.subprocess = execa({ - shell: BaseTerminal.getExecaShellPath() ?? getShell(), + shell: BaseTerminal.getExecaShellPath() || getShell(), cwd: this.terminal.getCurrentWorkingDirectory(), all: true, // Ignore stdin to ensure non-interactive mode and prevent hanging From f0c2d640e3b48d9317d3bbefea42a6059bbfe124 Mon Sep 17 00:00:00 2001 From: xcloudx01 Date: Sat, 19 Sep 2026 23:21:51 +1000 Subject: [PATCH 3/3] Tighten shell path assertion Assert the exact mocked PowerShell path instead of matching only the executable name. --- .../terminal/__tests__/ExecaTerminalProcess.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index 9fd19d067a..94f627200c 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -221,7 +221,7 @@ describe("ExecaTerminalProcess", () => { const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\pwsh.exe") await terminalProcess.run("echo test") expect(getShellSpy).toHaveBeenCalledTimes(1) - expect(capturedShellOption().shell).toContain("pwsh.exe") + expect(capturedShellOption().shell).toBe("C:\\Windows\\System32\\pwsh.exe") }) it("uses PowerShell when VS Code resolves PowerShell and execaShellPath is unset", async () => {