Merge pull request #13365 from Kilo-Org/support-configurable-powershell-shell

fix: prefer PowerShell 7 over legacy Windows PowerShell 5.1
This commit is contained in:
Marius
2026-08-24 15:53:21 +02:00
committed by GitHub
12 changed files with 231 additions and 8 deletions
@@ -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.
+19 -1
View File
@@ -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 }
+2 -1
View File
@@ -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),
),
@@ -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 })
}
})
})
@@ -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],
}
}
@@ -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],
}
}
@@ -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"
}
@@ -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 })
}
})
})
@@ -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({
@@ -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,
@@ -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<number, string>, 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,
@@ -1 +1 @@
export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell"
export { args, PowerShell, pwsh } from "@opencode-ai/core/kilocode/powershell"