From 596a04380bbc7eb5671c5af033037d354086b626 Mon Sep 17 00:00:00 2001 From: shanevcantwell <153727980+shanevcantwell@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:54:33 -0700 Subject: [PATCH] fix: restrict childProcess.spawn to local-only environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit childProcess.spawn executes on the extension host machine. With extensionKind: ["ui", "workspace"], the extension host almost always runs locally. For any remote workspace (SSH, WSL, Dev Container, Codespaces, tunnel), spawning locally either runs commands on the wrong machine or fails with ENOENT when the local shell doesn't exist in the extension host context. Replace ENABLED_FOR_REMOTES (which listed all remote types) with LOCAL_ONLY (just "" and "local"). All remote types now fall through to ide.runCommand() which delegates to VS Code's integrated terminal and executes in the correct remote environment. The isWindowsHostWithRemote guard is removed as it is now redundant — the platform-agnostic check handles all cases. Co-Authored-By: Claude Opus 4.6 --- .../implementations/runTerminalCommand.ts | 40 ++++------ .../runTerminalCommand.vitest.ts | 74 +++++++------------ 2 files changed, 42 insertions(+), 72 deletions(-) diff --git a/core/tools/implementations/runTerminalCommand.ts b/core/tools/implementations/runTerminalCommand.ts index 351ee75cae6..f675818c25d 100644 --- a/core/tools/implementations/runTerminalCommand.ts +++ b/core/tools/implementations/runTerminalCommand.ts @@ -93,17 +93,14 @@ const getColorEnv = () => ({ CLICOLOR_FORCE: "1", }); -const ENABLED_FOR_REMOTES = [ - "", - "local", - "wsl", - "dev-container", - "devcontainer", - "ssh-remote", - "attached-container", - "codespaces", - "tunnel", -]; +// Only spawn processes locally when there is no remote workspace. +// With extensionKind: ["ui", "workspace"], the extension host almost always +// runs on the local machine. childProcess.spawn() executes on the extension +// host, so for any remote workspace it would run commands on the wrong machine +// (or fail with ENOENT when the local shell doesn't match the remote OS). +// All remote types delegate to ide.runCommand() which routes through VS Code's +// integrated terminal and executes in the correct remote environment. +const LOCAL_ONLY = ["", "local"]; export const runTerminalCommandImpl: ToolImpl = async (args, extras) => { const command = getStringArg(args, "command"); @@ -114,17 +111,7 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => { const ideInfo = await extras.ide.getIdeInfo(); const toolCallId = extras.toolCallId || ""; - // When the extension host runs on Windows but connects to a remote workspace - // (WSL, Dev Container, SSH, etc.), we can't spawn shells directly — the - // platform is "win32" but commands should run in the remote's Linux/macOS. - // Use ide.runCommand() instead to let VS Code handle the remote execution. - const isWindowsHostWithRemote = - process.platform === "win32" && !["", "local"].includes(ideInfo.remoteName); - - if ( - ENABLED_FOR_REMOTES.includes(ideInfo.remoteName) && - !isWindowsHostWithRemote - ) { + if (LOCAL_ONLY.includes(ideInfo.remoteName)) { // For streaming output if (extras.onPartialOutput) { try { @@ -453,16 +440,17 @@ export const runTerminalCommandImpl: ToolImpl = async (args, extras) => { } } - // For remote environments, just run the command - // Note: waitForCompletion is not supported in remote environments yet + // For remote environments (SSH, WSL, Dev Container, Codespaces, etc.), + // delegate to VS Code's integrated terminal which handles remote execution. + // Note: output capture and waitForCompletion are not yet supported for remotes. await extras.ide.runCommand(command); return [ { name: "Terminal", description: "Terminal command output", content: - "Terminal output not available. This is only available in local development environments and not in SSH environments for example.", - status: "Command failed", + "Command executed in remote terminal. Output capture is not yet available for remote environments.", + status: "Command executed", }, ]; }; diff --git a/core/tools/implementations/runTerminalCommand.vitest.ts b/core/tools/implementations/runTerminalCommand.vitest.ts index ea502f7c015..1f5b668db9e 100644 --- a/core/tools/implementations/runTerminalCommand.vitest.ts +++ b/core/tools/implementations/runTerminalCommand.vitest.ts @@ -271,11 +271,8 @@ describe("runTerminalCommandImpl", () => { // In remote environments, it should use the IDE's runCommand expect(mockRunCommand).toHaveBeenCalledWith("echo 'test'"); - // Match the actual output message - expect(result[0].content).toContain("Terminal output not available"); - expect(result[0].content).toContain("SSH environments"); - // Verify status field indicates command failed in remote environments - expect(result[0].status).toBe("Command failed"); + expect(result[0].content).toContain("Command executed in remote terminal"); + expect(result[0].status).toBe("Command executed"); }); it("should handle errors when executing invalid commands", async () => { @@ -608,7 +605,7 @@ describe("runTerminalCommandImpl", () => { }); describe("remote environment handling", () => { - it("should use ide.runCommand for non-enabled remote environments", async () => { + it("should use ide.runCommand for remote environments", async () => { const extras = createMockExtras({ remoteName: "some-unsupported-remote", }); @@ -619,7 +616,9 @@ describe("runTerminalCommandImpl", () => { ); expect(mockRunCommand).toHaveBeenCalledWith("echo test"); - expect(result[0].content).toContain("Terminal output not available"); + expect(result[0].content).toContain( + "Command executed in remote terminal", + ); }); it("should handle local environment with file URIs", async () => { @@ -633,50 +632,33 @@ describe("runTerminalCommandImpl", () => { ).resolves.toBeDefined(); }); - it("should handle WSL environment", async () => { - mockGetWorkspaceDirs.mockResolvedValue(["file:///home/user/workspace"]); - - await expect( - runTerminalCommandImpl( - { command: "echo test", waitForCompletion: false }, - createMockExtras({ remoteName: "wsl" }), - ), - ).resolves.toBeDefined(); - }); + it("should use ide.runCommand for WSL environment", async () => { + // WSL is a remote — commands should execute in WSL, not on the host + const extras = createMockExtras({ remoteName: "wsl" }); - it("should use ide.runCommand when Windows host connects to WSL", async () => { - // When extension runs on Windows but connects to WSL, we can't spawn - // shells directly - must use ide.runCommand instead - const originalPlatform = process.platform; - Object.defineProperty(process, "platform", { value: "win32" }); - - try { - const extras = createMockExtras({ remoteName: "wsl" }); - - const result = await runTerminalCommandImpl( - { command: "echo test" }, - extras, - ); + const result = await runTerminalCommandImpl( + { command: "echo test" }, + extras, + ); - // Should fall back to ide.runCommand, not try to spawn powershell.exe - expect(mockRunCommand).toHaveBeenCalledWith("echo test"); - expect(result[0].content).toContain("Terminal output not available"); - } finally { - Object.defineProperty(process, "platform", { - value: originalPlatform, - }); - } + expect(mockRunCommand).toHaveBeenCalledWith("echo test"); + expect(result[0].content).toContain( + "Command executed in remote terminal", + ); }); - it("should handle dev-container environment", async () => { - mockGetWorkspaceDirs.mockResolvedValue(["file:///workspace"]); + it("should use ide.runCommand for dev-container environment", async () => { + const extras = createMockExtras({ remoteName: "dev-container" }); - await expect( - runTerminalCommandImpl( - { command: "echo test", waitForCompletion: false }, - createMockExtras({ remoteName: "dev-container" }), - ), - ).resolves.toBeDefined(); + const result = await runTerminalCommandImpl( + { command: "echo test" }, + extras, + ); + + expect(mockRunCommand).toHaveBeenCalledWith("echo test"); + expect(result[0].content).toContain( + "Command executed in remote terminal", + ); }); });