fix(non-interactive-env): add Windows/PowerShell support (#573)

* fix(non-interactive-env): add Windows/PowerShell support

- Create shared shell-env utility with cross-platform shell detection
- Detect shell type via PSModulePath, SHELL env vars, platform fallback
- Support Unix (export), PowerShell ($env:), and cmd.exe (set) syntax
- Add 41 comprehensive unit tests for shell-env utilities
- Add 5 cross-platform integration tests for hook behavior
- All 696 tests pass, type checking passes, build succeeds

Closes #566

* fix: address review feedback - add isNonInteractive check and cmd.exe % escaping

- Add isNonInteractive() check to only apply env vars in CI/non-interactive contexts (Issue #566)
- Fix cmd.exe percent sign escaping to prevent environment variable expansion
- Update test expectations for correct % escaping behavior

Resolves feedback from @greptile-apps and @cubic-dev-ai

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
Sisyphus
2026-01-15 16:04:06 +09:00
committed by GitHub
parent a38dc28e40
commit d6499cbe31
5 changed files with 564 additions and 36 deletions
+166 -1
View File
@@ -1,9 +1,31 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
describe("non-interactive-env hook", () => {
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
let originalPlatform: NodeJS.Platform
let originalEnv: Record<string, string | undefined>
beforeEach(() => {
originalPlatform = process.platform
originalEnv = {
SHELL: process.env.SHELL,
PSModulePath: process.env.PSModulePath,
}
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
} else {
delete process.env[key]
}
}
})
describe("git command modification", () => {
test("#given git command #when hook executes #then prepends export statement", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
@@ -147,4 +169,147 @@ describe("non-interactive-env hook", () => {
expect(output.message).toBeUndefined()
})
})
describe("cross-platform shell support", () => {
test("#given macOS platform #when git command executes #then uses unix export syntax", async () => {
delete process.env.PSModulePath
process.env.SHELL = "/bin/zsh"
Object.defineProperty(process, "platform", { value: "darwin" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toStartWith("export ")
expect(cmd).toContain(";")
expect(cmd).not.toContain("$env:")
expect(cmd).not.toContain("set ")
})
test("#given Linux platform #when git command executes #then uses unix export syntax", async () => {
delete process.env.PSModulePath
process.env.SHELL = "/bin/bash"
Object.defineProperty(process, "platform", { value: "linux" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git commit -m 'test'" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git commit")
})
test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("$env:")
expect(cmd).toContain("; git status")
expect(cmd).not.toStartWith("export ")
expect(cmd).not.toContain("set ")
})
test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => {
delete process.env.PSModulePath
delete process.env.SHELL
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git log" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("set ")
expect(cmd).toContain("&&")
expect(cmd).not.toStartWith("export ")
expect(cmd).not.toContain("$env:")
})
test("#given PowerShell #when values contain quotes #then escapes correctly", async () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toMatch(/\$env:\w+='[^']*'/)
})
test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => {
delete process.env.PSModulePath
delete process.env.SHELL
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toMatch(/set \w+="[^"]*"/)
})
test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git add file && git commit -m 'test'" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("$env:")
expect(cmd).toContain("; git add file && git commit")
})
})
})
+8 -35
View File
@@ -1,6 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
import { log } from "../../shared"
import { isNonInteractive } from "./detector"
import { log, detectShellType, buildEnvPrefix } from "../../shared"
export * from "./constants"
export * from "./detector"
@@ -19,35 +20,6 @@ function detectBannedCommand(command: string): string | undefined {
return undefined
}
/**
* Shell-escape a value for use in VAR=value prefix.
* Wraps in single quotes if contains special chars.
*/
function shellEscape(value: string): string {
// Empty string needs quotes
if (value === "") return "''"
// If contains special chars, wrap in single quotes (escape existing single quotes)
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
return `'${value.replace(/'/g, "'\\''")}'`
}
return value
}
/**
* Build export statement for environment variables.
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
*
* Previous approach used VAR=value prefix which only applies to the first command.
* OpenCode's bash tool ignores args.env, so we must prepend to command.
*/
function buildEnvPrefix(env: Record<string, string>): string {
const exports = Object.entries(env)
.map(([key, value]) => `${key}=${shellEscape(value)}`)
.join(" ")
return `export ${exports};`
}
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
@@ -74,11 +46,12 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return
}
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
// ignoring any args.env we might set. Prepend export statement to command.
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
// in a chain (e.g., `git add file && git rebase --continue`).
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
if (!isNonInteractive()) {
return
}
const shellType = detectShellType()
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {