diff --git a/.changeset/prefer-powershell-7-on-windows.md b/.changeset/prefer-powershell-7-on-windows.md new file mode 100644 index 00000000000..5d0f1090a7f --- /dev/null +++ b/.changeset/prefer-powershell-7-on-windows.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prefer PowerShell 7 over legacy Windows PowerShell 5.1 when running agent commands on Windows. PowerShell 7 installs are now found even when `pwsh` is missing from PATH, Agent Manager setup and run scripts launch pwsh when available, and an explicit `shell` in kilo.json still overrides detection. diff --git a/packages/core/src/kilocode/powershell.ts b/packages/core/src/kilocode/powershell.ts index 866f215e043..13a0e17ab10 100644 --- a/packages/core/src/kilocode/powershell.ts +++ b/packages/core/src/kilocode/powershell.ts @@ -1,7 +1,25 @@ +import { statSync } from "fs" +import path from "path" +import { which } from "../util/which" + export function args(command: string) { return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] } +export const locations = (env: NodeJS.ProcessEnv = process.env) => + [ + env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"), + env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"), + env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"), + ] + .filter((item): item is string => Boolean(item)) + .map((root) => path.join(root, "pwsh.exe")) + +export const probe = (env: NodeJS.ProcessEnv = process.env) => + locations(env).filter((file) => statSync(file, { throwIfNoEntry: false })?.isFile()) + +export const pwsh = (env: NodeJS.ProcessEnv = process.env) => which("pwsh", env) ?? probe(env)[0] + const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding; @@ -123,4 +141,4 @@ function block(command: string, start: number, open: string, close: string) { } } -export const PowerShell = { args } +export const PowerShell = { args, locations, probe, pwsh } diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index f92906aebee..821e69082a5 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -99,7 +99,8 @@ function resolve(file: string) { function win() { return Array.from( new Set( - [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] + // kilocode_change - probe known PowerShell 7 install locations so legacy 5.1 is not picked when pwsh is off PATH + [PowerShell.pwsh(), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] // kilocode_change .filter((item): item is string => Boolean(item)) .map(full), ), diff --git a/packages/core/test/kilocode/powershell.test.ts b/packages/core/test/kilocode/powershell.test.ts new file mode 100644 index 00000000000..edca08ae54f --- /dev/null +++ b/packages/core/test/kilocode/powershell.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test" +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { Shell } from "@opencode-ai/core/shell" +import { PowerShell } from "@opencode-ai/core/kilocode/powershell" +import { which } from "@opencode-ai/core/util/which" + +const LEGACY = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + +const knownLocations = () => { + const roots = [ + process.env.ProgramFiles && path.join(process.env.ProgramFiles, "PowerShell", "7"), + process.env["ProgramFiles(x86)"] && path.join(process.env["ProgramFiles(x86)"], "PowerShell", "7"), + process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps"), + ].filter((item): item is string => Boolean(item)) + return roots.map((root) => path.join(root, "pwsh.exe")).filter((file) => existsSync(file)) +} + +const pwshInstalled = () => Boolean(which("pwsh")) || knownLocations().length > 0 + +// Remove every PATH directory that can resolve pwsh or powershell so detection +// cannot fall back to PATH lookup and must find installs on its own. +const withoutPowershellDirs = () => + (process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean) + .filter((dir) => !/powershell/i.test(dir) && !existsSync(path.join(dir, "pwsh.exe"))) + .join(path.delimiter) + +function withEnv(env: { PATH?: string; SHELL?: string }, fn: () => void) { + const prevPath = process.env.PATH + const prevShell = process.env.SHELL + if (env.PATH === undefined) delete process.env.PATH + else process.env.PATH = env.PATH + if (env.SHELL === undefined) delete process.env.SHELL + else process.env.SHELL = env.SHELL + Shell.preferred.reset() + Shell.acceptable.reset() + try { + fn() + } finally { + if (prevPath === undefined) delete process.env.PATH + else process.env.PATH = prevPath + if (prevShell === undefined) delete process.env.SHELL + else process.env.SHELL = prevShell + Shell.preferred.reset() + Shell.acceptable.reset() + } +} + +if (process.platform === "win32") { + describe("windows powershell selection", () => { + test("prefers an installed powershell 7 when pwsh is absent from PATH", () => { + if (!pwshInstalled()) return + withEnv({ PATH: withoutPowershellDirs(), SHELL: undefined }, () => { + expect(Shell.name(Shell.preferred())).toBe("pwsh") + expect(Shell.name(Shell.acceptable())).toBe("pwsh") + }) + }) + + test("prefers pwsh over legacy 5.1 on the unmodified PATH", () => { + if (!pwshInstalled()) return + withEnv({ SHELL: undefined }, () => { + expect(Shell.name(Shell.preferred())).toBe("pwsh") + }) + }) + + test("explicit shell config still overrides detection", () => { + if (!existsSync(LEGACY)) return + expect(Shell.preferred(LEGACY)).toBe(LEGACY) + expect(Shell.acceptable(LEGACY)).toBe(LEGACY) + }) + }) +} + +describe("powershell install probing", () => { + test("lists known locations in priority order", () => { + expect( + PowerShell.locations({ + ProgramFiles: "C:\\Program Files", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", + }), + ).toEqual([ + path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"), + path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"), + ]) + }) + + test("skips unset environment roots", () => { + expect(PowerShell.locations({})).toEqual([]) + }) + + test("probe and pwsh resolve an installed pwsh outside PATH", () => { + const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-")) + try { + const dir = path.join(root, "PowerShell", "7") + mkdirSync(dir, { recursive: true }) + const file = path.join(dir, "pwsh.exe") + writeFileSync(file, "") + expect(PowerShell.probe({ ProgramFiles: root })).toEqual([file]) + expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBe(file) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + test("probe ignores location roots without pwsh", () => { + const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-empty-")) + try { + mkdirSync(path.join(root, "PowerShell", "7"), { recursive: true }) + expect(PowerShell.probe({ ProgramFiles: root })).toEqual([]) + expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts index 6aa46ff7890..3f2ecb5662d 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -5,6 +5,7 @@ * actual execution to an injected RunTask callback (provided by the caller). */ +import { powershellCommand } from "../util/powershell" import { SetupScriptService, type SetupScriptInfo } from "./SetupScriptService" interface SetupScriptEnvironment { @@ -31,7 +32,7 @@ function quoteCmdArg(value: string): string { export function buildSetupTaskCommand(script: SetupScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: "powershell.exe", + command: powershellCommand(), args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path], } } diff --git a/packages/kilo-vscode/src/agent-manager/run/service.ts b/packages/kilo-vscode/src/agent-manager/run/service.ts index a2d13ac3198..197f1c65794 100644 --- a/packages/kilo-vscode/src/agent-manager/run/service.ts +++ b/packages/kilo-vscode/src/agent-manager/run/service.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs" import * as path from "node:path" import { KILO_DIR } from "../constants" +import { powershellCommand } from "../../util/powershell" const RUN_SCRIPT_FILENAME = "run-script" const RUN_SCRIPT_SHELL_FILENAME = "run-script.sh" @@ -85,7 +86,7 @@ function validated(file: string, dir: string): boolean { export function buildRunTaskCommand(script: RunScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: "powershell.exe", + command: powershellCommand(), args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path], } } diff --git a/packages/kilo-vscode/src/util/powershell.ts b/packages/kilo-vscode/src/util/powershell.ts new file mode 100644 index 00000000000..07543571ff1 --- /dev/null +++ b/packages/kilo-vscode/src/util/powershell.ts @@ -0,0 +1,33 @@ +import { statSync } from "node:fs" +import * as path from "node:path" + +/** + * Well-known PowerShell 7 install locations on Windows. The Store install only + * exposes `pwsh.exe` through the WindowsApps execution alias, which is often + * missing from the PATH of spawned child processes. + */ +export function locations(env: NodeJS.ProcessEnv = process.env): string[] { + const roots = [ + env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"), + env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"), + env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"), + ].filter((item): item is string => Boolean(item)) + return roots.map((root) => path.join(root, "pwsh.exe")) +} + +function exists(file: string): boolean { + return statSync(file, { throwIfNoEntry: false })?.isFile() === true +} + +export function pwshPath(env: NodeJS.ProcessEnv = process.env): string | undefined { + const dirs = [...(env.PATH ?? env.Path ?? "").split(path.delimiter), ...locations(env)] + return dirs + .filter(Boolean) + .map((dir) => path.join(dir, "pwsh.exe")) + .find(exists) +} + +/** Prefer PowerShell 7; legacy 5.1 writes UTF-16LE BOM output on redirection. */ +export function powershellCommand(env: NodeJS.ProcessEnv = process.env): string { + return pwshPath(env) ?? "powershell.exe" +} diff --git a/packages/kilo-vscode/tests/unit/powershell.test.ts b/packages/kilo-vscode/tests/unit/powershell.test.ts new file mode 100644 index 00000000000..cb3946bea20 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/powershell.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { locations, powershellCommand, pwshPath } from "../../src/util/powershell" + +describe("powershellCommand", () => { + it("lists known Windows install locations in priority order", () => { + expect( + locations({ + ProgramFiles: "C:\\Program Files", + "ProgramFiles(x86)": "C:\\Program Files (x86)", + LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", + }), + ).toEqual([ + path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"), + path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"), + ]) + expect(locations({})).toEqual([]) + }) + + it("falls back to legacy powershell.exe when nothing is found", () => { + expect(powershellCommand({ PATH: "" })).toBe("powershell.exe") + }) + + it("prefers a pwsh.exe found on the injected PATH", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pwsh-path-")) + try { + const file = path.join(root, "pwsh.exe") + fs.writeFileSync(file, "") + expect(pwshPath({ PATH: root })).toBe(file) + expect(powershellCommand({ PATH: root })).toBe(file) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/run-script-service.test.ts b/packages/kilo-vscode/tests/unit/run-script-service.test.ts index f1823bde2a6..18360635673 100644 --- a/packages/kilo-vscode/tests/unit/run-script-service.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-service.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { buildRunTaskCommand, RunScriptService } from "../../src/agent-manager/run/service" +import { powershellCommand } from "../../src/util/powershell" function tmpdir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "run-script-service-test-")) @@ -59,7 +60,7 @@ describe("RunScriptService", () => { args: ["/tmp/run-script"], }) expect(buildRunTaskCommand({ path: "C:\\repo\\.kilo\\run-script.ps1", kind: "powershell" })).toEqual({ - command: "powershell.exe", + command: powershellCommand(), args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\repo\\.kilo\\run-script.ps1"], }) expect(buildRunTaskCommand({ path: "C:\\repo path\\.kilo\\run-script.cmd", kind: "cmd" })).toEqual({ diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 3433baf2431..296f52ea41f 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -7,6 +7,7 @@ import { Instance, type InstanceContext } from "@/kilocode/instance" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { model as modelEnv } from "@/kilocode/process/env" import { SessionID } from "@/session/schema" +import { PowerShell } from "@/kilocode/shell/shell" import { Shell } from "@opencode-ai/core/shell" import { ProjectV2 } from "@opencode-ai/core/project" import { Process } from "@/util/process" @@ -32,6 +33,7 @@ import * as Ports from "./ports" export namespace BackgroundProcess { const log = Log.create({ service: "background-process" }) + const pwsh = PowerShell.pwsh() ?? "powershell.exe" const MAX = 200 * 1024 const KILL_MS = 3_000 const READY_MS = 30_000 @@ -669,7 +671,7 @@ export namespace BackgroundProcess { const token = active.token if (!pid || !token) return "unknown" const query = `$p=Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($p) { [Console]::Out.Write($p.CommandLine) }` - const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], { + const out = await Process.text([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], { nothrow: true, abort: AbortSignal.timeout(2_000), timeout: 2_000, diff --git a/packages/opencode/src/kilocode/background-process/runner.ts b/packages/opencode/src/kilocode/background-process/runner.ts index 37d719497e6..94a1fcc7e77 100644 --- a/packages/opencode/src/kilocode/background-process/runner.ts +++ b/packages/opencode/src/kilocode/background-process/runner.ts @@ -1,4 +1,5 @@ import { KiloPtySelfCommand } from "@/kilocode/pty/self-command" +import { PowerShell } from "@/kilocode/shell/shell" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { isRecord } from "@/util/record" @@ -11,6 +12,7 @@ export namespace BackgroundProcessRunner { const MODE = 0o600 const MAX = 1024 * 1024 const KEEP = 200 * 1024 + const pwsh = PowerShell.pwsh() ?? "powershell.exe" export type Input = { token: string @@ -96,7 +98,7 @@ export namespace BackgroundProcessRunner { async function descendants(root: number, seen: Map, active: boolean) { const query = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate | ConvertTo-Json -Compress" - const out = await Process.text(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", query], { + const out = await Process.text([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], { nothrow: true, abort: AbortSignal.timeout(2_000), timeout: 2_000, diff --git a/packages/opencode/src/kilocode/shell/shell.ts b/packages/opencode/src/kilocode/shell/shell.ts index c27d814ada7..904a1e9dcd9 100644 --- a/packages/opencode/src/kilocode/shell/shell.ts +++ b/packages/opencode/src/kilocode/shell/shell.ts @@ -1 +1 @@ -export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell" +export { args, PowerShell, pwsh } from "@opencode-ai/core/kilocode/powershell"