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[0] let originalPlatform: NodeJS.Platform let originalEnv: Record beforeEach(() => { originalPlatform = process.platform originalEnv = { SHELL: process.env.SHELL, PSModulePath: process.env.PSModulePath, MSYSTEM: process.env.MSYSTEM, ComSpec: process.env.ComSpec, CI: process.env.CI, OPENCODE_NON_INTERACTIVE: process.env.OPENCODE_NON_INTERACTIVE, } // given clean Unix-like environment for all tests // This prevents CI environments (which may have PSModulePath set) from // triggering PowerShell detection in tests that expect Unix behavior delete process.env.PSModulePath process.env.SHELL = "/bin/bash" process.env.OPENCODE_NON_INTERACTIVE = "true" }) 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) const output: { args: Record; 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_EDITOR=:") expect(cmd).toContain("EDITOR=:") expect(cmd).toContain("PAGER=cat") expect(cmd).toContain("; git commit -m 'test'") }) test("#given chained git commands #when hook executes #then export applies to all", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "git add file && git rebase --continue" }, } 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 add file && git rebase --continue") }) test("#given non-git bash command #when hook executes #then command unchanged", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "ls -la" }, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "1" }, output ) expect(output.args.command).toBe("ls -la") }) test("#given non-bash tool #when hook executes #then command unchanged", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "git status" }, } await hook["tool.execute.before"]( { tool: "Read", sessionID: "test", callID: "1" }, output ) expect(output.args.command).toBe("git status") }) test("#given empty command #when hook executes #then no error", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: {}, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "1" }, output ) expect(output.args.command).toBeUndefined() }) test("#given git command already has prefix #when hook executes again #then does not duplicate prefix", async () => { const hook = createNonInteractiveEnvHook(mockCtx) // First call: transforms the command const output1: { args: Record; message?: string } = { args: { command: "git commit -m 'test'" }, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "1" }, output1 ) const firstResult = output1.args.command as string expect(firstResult).toStartWith("export ") // Second call: takes the already-prefixed command const output2: { args: Record; message?: string } = { args: { command: firstResult }, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "2" }, output2 ) // Should be exactly the same (no double prefix) expect(output2.args.command).toBe(firstResult) }) }) describe("shell escaping", () => { test("#given git command #when building prefix #then VISUAL properly escaped", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("VISUAL=''") }) test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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 for (const key of Object.keys(NON_INTERACTIVE_ENV)) { expect(cmd).toContain(`${key}=`) } }) }) describe("banned command detection", () => { test("#given vim command #when hook executes #then warning message set", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "vim file.txt" }, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "1" }, output ) expect(output.message).toContain("vim") expect(output.message).toContain("interactive") }) test("#given safe command #when hook executes #then no warning", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "ls -la" }, } await hook["tool.execute.before"]( { tool: "bash", sessionID: "test", callID: "1" }, output ) expect(output.message).toBeUndefined() }) }) describe("platform-aware shell syntax", () => { 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; 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; 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 cmd environment with PSModulePath #when bash tool git command executes #then uses cmd syntax", async () => { delete process.env.SHELL delete process.env.MSYSTEM process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("set ") expect(cmd).toContain(" && git status") expect(cmd).toContain('GIT_EDITOR=":"') expect(cmd).not.toContain("$env:") expect(cmd).not.toContain("export ") }) test("#given Windows SHELL=cmd.exe #when bash tool git command executes #then uses cmd syntax", async () => { process.env.SHELL = "C:\\Windows\\System32\\cmd.exe" delete process.env.MSYSTEM process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("set ") expect(cmd).toContain(" && git status") expect(cmd).not.toContain("$env:") expect(cmd).not.toContain("export ") }) test("#given Windows ComSpec=pwsh.exe without SHELL #when bash tool git command executes #then uses powershell syntax", async () => { delete process.env.SHELL delete process.env.MSYSTEM process.env.ComSpec = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("$env:") expect(cmd).toContain("; git status") expect(cmd).not.toContain("set ") expect(cmd).not.toContain("export ") }) test("#given Windows SHELL=pwsh.exe #when bash tool git command executes #then uses powershell syntax", async () => { process.env.SHELL = "C:\\Program Files\\PowerShell\\7\\pwsh.exe" delete process.env.MSYSTEM process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("$env:") expect(cmd).toContain("; git status") expect(cmd).not.toContain("set ") expect(cmd).not.toContain("export ") }) test("#given Windows without SHELL env #when bash tool git command executes #then uses cmd syntax", async () => { delete process.env.PSModulePath delete process.env.SHELL delete process.env.MSYSTEM Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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).toStartWith("set ") expect(cmd).toContain(" && git log") expect(cmd).toContain('GIT_EDITOR=":"') expect(cmd).not.toContain("$env:") expect(cmd).not.toContain("export ") }) test("#given Windows Git Bash SHELL=/usr/bin/bash #when git command executes #then uses powershell syntax (#3607)", async () => { // Regression for #3607: OpenCode on Windows runs the bash tool through // PowerShell by default, regardless of a Unix-shaped SHELL set by Git // Bash. The export prefix is invalid PowerShell, so we must use // PowerShell syntax even when SHELL points at /usr/bin/bash. delete process.env.PSModulePath process.env.SHELL = "/usr/bin/bash" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("$env:") expect(cmd).toContain("; git status") expect(cmd).not.toContain("export ") }) test("#given Windows MSYSTEM=MINGW64 without SHELL #when git command executes #then uses powershell syntax (#3607)", async () => { // Regression for #3607: MSYSTEM is permanently set on systems with Git // Bash installed, but OpenCode on Windows still spawns PowerShell. // MSYSTEM alone must not select Unix env-prefix syntax. delete process.env.SHELL process.env.MSYSTEM = "MINGW64" process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("$env:") expect(cmd).toContain("; git status") expect(cmd).not.toContain("export ") }) test("#given Windows platform #when chained git commands via bash tool #then uses cmd syntax", async () => { delete process.env.PSModulePath delete process.env.SHELL delete process.env.MSYSTEM Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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).toStartWith("set ") expect(cmd).toContain(" && git add file && git commit") expect(cmd).toContain('GIT_EDITOR=":"') expect(cmd).not.toContain("export ") expect(cmd).not.toContain("$env:") }) test("#given SHELL=/bin/bash on win32 #when git command executes #then uses powershell syntax (#3607)", async () => { // Regression for #3607: a Unix-shaped SHELL value (Git Bash sets // SHELL=/bin/bash or /usr/bin/bash) does NOT mean OpenCode will run // the bash tool in a Unix shell on Windows — OpenCode spawns // PowerShell, so the env prefix must use PowerShell syntax. // WSL is not affected by this assertion because in WSL, // process.platform === "linux", not "win32". delete process.env.PSModulePath process.env.SHELL = "/bin/bash" Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("$env:") expect(cmd).toContain("; git status") expect(cmd).not.toContain("export ") }) test("#given PSModulePath set on non-Windows #when git command executes #then uses powershell syntax", async () => { // PowerShell detection via PSModulePath should work regardless of platform delete process.env.SHELL delete process.env.MSYSTEM process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules" Object.defineProperty(process, "platform", { value: "linux" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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).toStartWith("$env:") expect(cmd).toContain("; git log") expect(cmd).not.toContain("export ") }) test("#given no SHELL and no PSModulePath on win32 #when git command executes #then uses cmd syntax", async () => { // Platform fallback: win32 without env hints should use cmd delete process.env.SHELL delete process.env.PSModulePath delete process.env.MSYSTEM Object.defineProperty(process, "platform", { value: "win32" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("set ") expect(cmd).toContain(" && git status") expect(cmd).toContain('GIT_EDITOR=":"') expect(cmd).not.toContain("export ") expect(cmd).not.toContain("$env:") }) test("#given no SHELL and no PSModulePath on linux #when git command executes #then uses unix syntax", async () => { // Platform fallback: non-win32 without env hints should use unix delete process.env.SHELL delete process.env.PSModulePath Object.defineProperty(process, "platform", { value: "linux" }) const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; 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("; git status") expect(cmd).not.toContain("$env:") expect(cmd).not.toContain("set ") }) }) })