diff --git a/.changeset/restore-safe-powershell-detection.md b/.changeset/restore-safe-powershell-detection.md new file mode 100644 index 00000000000..862405bc78f --- /dev/null +++ b/.changeset/restore-safe-powershell-detection.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent inaccessible Windows PowerShell execution aliases from blocking CLI and extension startup. diff --git a/packages/core/src/kilocode/powershell.ts b/packages/core/src/kilocode/powershell.ts index 13a0e17ab10..866f215e043 100644 --- a/packages/core/src/kilocode/powershell.ts +++ b/packages/core/src/kilocode/powershell.ts @@ -1,25 +1,7 @@ -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; @@ -141,4 +123,4 @@ function block(command: string, start: number, open: string, close: string) { } } -export const PowerShell = { args, locations, probe, pwsh } +export const PowerShell = { args } diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 821e69082a5..f92906aebee 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -99,8 +99,7 @@ function resolve(file: string) { function win() { return Array.from( new Set( - // 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 + [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] .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 deleted file mode 100644 index edca08ae54f..00000000000 --- a/packages/core/test/kilocode/powershell.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 3f2ecb5662d..6aa46ff7890 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -5,7 +5,6 @@ * actual execution to an injected RunTask callback (provided by the caller). */ -import { powershellCommand } from "../util/powershell" import { SetupScriptService, type SetupScriptInfo } from "./SetupScriptService" interface SetupScriptEnvironment { @@ -32,7 +31,7 @@ function quoteCmdArg(value: string): string { export function buildSetupTaskCommand(script: SetupScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: powershellCommand(), + command: "powershell.exe", 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 197f1c65794..a2d13ac3198 100644 --- a/packages/kilo-vscode/src/agent-manager/run/service.ts +++ b/packages/kilo-vscode/src/agent-manager/run/service.ts @@ -1,7 +1,6 @@ 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" @@ -86,7 +85,7 @@ function validated(file: string, dir: string): boolean { export function buildRunTaskCommand(script: RunScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: powershellCommand(), + command: "powershell.exe", 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 deleted file mode 100644 index 07543571ff1..00000000000 --- a/packages/kilo-vscode/src/util/powershell.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index cb3946bea20..00000000000 --- a/packages/kilo-vscode/tests/unit/powershell.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 18360635673..f1823bde2a6 100644 --- a/packages/kilo-vscode/tests/unit/run-script-service.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-service.test.ts @@ -3,7 +3,6 @@ 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-")) @@ -60,7 +59,7 @@ describe("RunScriptService", () => { args: ["/tmp/run-script"], }) expect(buildRunTaskCommand({ path: "C:\\repo\\.kilo\\run-script.ps1", kind: "powershell" })).toEqual({ - command: powershellCommand(), + command: "powershell.exe", 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 296f52ea41f..3433baf2431 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -7,7 +7,6 @@ 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" @@ -33,7 +32,6 @@ 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 @@ -671,7 +669,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([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], { + const out = await Process.text(["powershell.exe", "-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 94a1fcc7e77..37d719497e6 100644 --- a/packages/opencode/src/kilocode/background-process/runner.ts +++ b/packages/opencode/src/kilocode/background-process/runner.ts @@ -1,5 +1,4 @@ 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" @@ -12,7 +11,6 @@ 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 @@ -98,7 +96,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([pwsh, "-NoProfile", "-NonInteractive", "-Command", query], { + const out = await Process.text(["powershell.exe", "-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 904a1e9dcd9..c27d814ada7 100644 --- a/packages/opencode/src/kilocode/shell/shell.ts +++ b/packages/opencode/src/kilocode/shell/shell.ts @@ -1 +1 @@ -export { args, PowerShell, pwsh } from "@opencode-ai/core/kilocode/powershell" +export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell"