mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
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:
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user