From 4a9bdc89aa3217558d901666110b843a948f4df5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 2 Jan 2026 22:19:46 +0900 Subject: [PATCH] fix(non-interactive-env): prepend env vars directly to git command string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/hooks/non-interactive-env/index.test.ts | 133 ++++++++++++++++++++ src/hooks/non-interactive-env/index.ts | 45 +++++-- 2 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 src/hooks/non-interactive-env/index.test.ts diff --git a/src/hooks/non-interactive-env/index.test.ts b/src/hooks/non-interactive-env/index.test.ts new file mode 100644 index 0000000..627c0ee --- /dev/null +++ b/src/hooks/non-interactive-env/index.test.ts @@ -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[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; 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; 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() + }) + }) + + 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() + }) + }) +}) diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index 74cd8e6..08622c2 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -19,6 +19,30 @@ 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 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 { + return Object.entries(env) + .map(([key, value]) => `${key}=${shellEscape(value)}`) + .join(" ") +} + export function createNonInteractiveEnvHook(_ctx: PluginInput) { return { "tool.execute.before": async ( @@ -34,20 +58,25 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) { return } - output.args.env = { - ...process.env, - ...(output.args.env as Record | undefined), - ...NON_INTERACTIVE_ENV, - } - const bannedCmd = detectBannedCommand(command) if (bannedCmd) { 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, - env: NON_INTERACTIVE_ENV, + envPrefix, }) }, }