fix(non-interactive-env): prepend env vars directly to git command string
OpenCode's bash tool ignores args.env and uses hardcoded process.env in spawn(). Work around this by prepending GIT_EDITOR, EDITOR, VISUAL, and PAGER env vars directly to the command string. Only applies to git commands to avoid bloating non-git commands. Added shellEscape() and buildEnvPrefix() helper functions to properly escape env var values and construct the prefix string. 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
133
src/hooks/non-interactive-env/index.test.ts
Normal file
133
src/hooks/non-interactive-env/index.test.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||||
|
|
||||||
|
describe("non-interactive-env hook", () => {
|
||||||
|
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||||
|
|
||||||
|
describe("git command modification", () => {
|
||||||
|
test("#given git command #when hook executes #then prepends env vars", async () => {
|
||||||
|
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).toContain("GIT_EDITOR=:")
|
||||||
|
expect(cmd).toContain("EDITOR=:")
|
||||||
|
expect(cmd).toContain("PAGER=cat")
|
||||||
|
expect(cmd).toEndWith(" git commit -m 'test'")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
|
||||||
|
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||||
|
const output: { args: Record<string, unknown>; 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<string, unknown>; 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<string, unknown>; message?: string } = {
|
||||||
|
args: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
await hook["tool.execute.before"](
|
||||||
|
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.args.command).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("shell escaping", () => {
|
||||||
|
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
|
||||||
|
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("VISUAL=''")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
|
||||||
|
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
|
||||||
|
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<string, unknown>; 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<string, unknown>; message?: string } = {
|
||||||
|
args: { command: "ls -la" },
|
||||||
|
}
|
||||||
|
|
||||||
|
await hook["tool.execute.before"](
|
||||||
|
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(output.message).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -19,6 +19,30 @@ function detectBannedCommand(command: string): string | undefined {
|
|||||||
return 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 env prefix string: VAR1=val1 VAR2=val2 ...
|
||||||
|
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||||
|
*/
|
||||||
|
function buildEnvPrefix(env: Record<string, string>): string {
|
||||||
|
return Object.entries(env)
|
||||||
|
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||||
return {
|
return {
|
||||||
"tool.execute.before": async (
|
"tool.execute.before": async (
|
||||||
@@ -34,20 +58,25 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
output.args.env = {
|
|
||||||
...process.env,
|
|
||||||
...(output.args.env as Record<string, string> | undefined),
|
|
||||||
...NON_INTERACTIVE_ENV,
|
|
||||||
}
|
|
||||||
|
|
||||||
const bannedCmd = detectBannedCommand(command)
|
const bannedCmd = detectBannedCommand(command)
|
||||||
if (bannedCmd) {
|
if (bannedCmd) {
|
||||||
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
|
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
|
||||||
}
|
}
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
|
// Only prepend env vars for git commands (editor blocking, pager, etc.)
|
||||||
|
const isGitCommand = /\bgit\b/.test(command)
|
||||||
|
if (!isGitCommand) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||||
|
// ignoring any args.env we might set. Prepend to command instead.
|
||||||
|
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||||
|
output.args.command = `${envPrefix} ${command}`
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
env: NON_INTERACTIVE_ENV,
|
envPrefix,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user