Skip to content

Commit 4a9bdc8

Browse files
committed
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)
1 parent 50afbf7 commit 4a9bdc8

File tree

2 files changed

+170
-8
lines changed

2 files changed

+170
-8
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
3+
4+
describe("non-interactive-env hook", () => {
5+
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
6+
7+
describe("git command modification", () => {
8+
test("#given git command #when hook executes #then prepends env vars", async () => {
9+
const hook = createNonInteractiveEnvHook(mockCtx)
10+
const output: { args: Record<string, unknown>; message?: string } = {
11+
args: { command: "git commit -m 'test'" },
12+
}
13+
14+
await hook["tool.execute.before"](
15+
{ tool: "bash", sessionID: "test", callID: "1" },
16+
output
17+
)
18+
19+
const cmd = output.args.command as string
20+
expect(cmd).toContain("GIT_EDITOR=:")
21+
expect(cmd).toContain("EDITOR=:")
22+
expect(cmd).toContain("PAGER=cat")
23+
expect(cmd).toEndWith(" git commit -m 'test'")
24+
})
25+
26+
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
27+
const hook = createNonInteractiveEnvHook(mockCtx)
28+
const output: { args: Record<string, unknown>; message?: string } = {
29+
args: { command: "ls -la" },
30+
}
31+
32+
await hook["tool.execute.before"](
33+
{ tool: "bash", sessionID: "test", callID: "1" },
34+
output
35+
)
36+
37+
expect(output.args.command).toBe("ls -la")
38+
})
39+
40+
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
41+
const hook = createNonInteractiveEnvHook(mockCtx)
42+
const output: { args: Record<string, unknown>; message?: string } = {
43+
args: { command: "git status" },
44+
}
45+
46+
await hook["tool.execute.before"](
47+
{ tool: "Read", sessionID: "test", callID: "1" },
48+
output
49+
)
50+
51+
expect(output.args.command).toBe("git status")
52+
})
53+
54+
test("#given empty command #when hook executes #then no error", async () => {
55+
const hook = createNonInteractiveEnvHook(mockCtx)
56+
const output: { args: Record<string, unknown>; message?: string } = {
57+
args: {},
58+
}
59+
60+
await hook["tool.execute.before"](
61+
{ tool: "bash", sessionID: "test", callID: "1" },
62+
output
63+
)
64+
65+
expect(output.args.command).toBeUndefined()
66+
})
67+
})
68+
69+
describe("shell escaping", () => {
70+
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
71+
const hook = createNonInteractiveEnvHook(mockCtx)
72+
const output: { args: Record<string, unknown>; message?: string } = {
73+
args: { command: "git status" },
74+
}
75+
76+
await hook["tool.execute.before"](
77+
{ tool: "bash", sessionID: "test", callID: "1" },
78+
output
79+
)
80+
81+
const cmd = output.args.command as string
82+
expect(cmd).toContain("VISUAL=''")
83+
})
84+
85+
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
86+
const hook = createNonInteractiveEnvHook(mockCtx)
87+
const output: { args: Record<string, unknown>; message?: string } = {
88+
args: { command: "git log" },
89+
}
90+
91+
await hook["tool.execute.before"](
92+
{ tool: "bash", sessionID: "test", callID: "1" },
93+
output
94+
)
95+
96+
const cmd = output.args.command as string
97+
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
98+
expect(cmd).toContain(`${key}=`)
99+
}
100+
})
101+
})
102+
103+
describe("banned command detection", () => {
104+
test("#given vim command #when hook executes #then warning message set", async () => {
105+
const hook = createNonInteractiveEnvHook(mockCtx)
106+
const output: { args: Record<string, unknown>; message?: string } = {
107+
args: { command: "vim file.txt" },
108+
}
109+
110+
await hook["tool.execute.before"](
111+
{ tool: "bash", sessionID: "test", callID: "1" },
112+
output
113+
)
114+
115+
expect(output.message).toContain("vim")
116+
expect(output.message).toContain("interactive")
117+
})
118+
119+
test("#given safe command #when hook executes #then no warning", async () => {
120+
const hook = createNonInteractiveEnvHook(mockCtx)
121+
const output: { args: Record<string, unknown>; message?: string } = {
122+
args: { command: "ls -la" },
123+
}
124+
125+
await hook["tool.execute.before"](
126+
{ tool: "bash", sessionID: "test", callID: "1" },
127+
output
128+
)
129+
130+
expect(output.message).toBeUndefined()
131+
})
132+
})
133+
})

src/hooks/non-interactive-env/index.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,30 @@ function detectBannedCommand(command: string): string | undefined {
1919
return undefined
2020
}
2121

22+
/**
23+
* Shell-escape a value for use in VAR=value prefix.
24+
* Wraps in single quotes if contains special chars.
25+
*/
26+
function shellEscape(value: string): string {
27+
// Empty string needs quotes
28+
if (value === "") return "''"
29+
// If contains special chars, wrap in single quotes (escape existing single quotes)
30+
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
31+
return `'${value.replace(/'/g, "'\\''")}'`
32+
}
33+
return value
34+
}
35+
36+
/**
37+
* Build env prefix string: VAR1=val1 VAR2=val2 ...
38+
* OpenCode's bash tool ignores args.env, so we must prepend to command.
39+
*/
40+
function buildEnvPrefix(env: Record<string, string>): string {
41+
return Object.entries(env)
42+
.map(([key, value]) => `${key}=${shellEscape(value)}`)
43+
.join(" ")
44+
}
45+
2246
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
2347
return {
2448
"tool.execute.before": async (
@@ -34,20 +58,25 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
3458
return
3559
}
3660

37-
output.args.env = {
38-
...process.env,
39-
...(output.args.env as Record<string, string> | undefined),
40-
...NON_INTERACTIVE_ENV,
41-
}
42-
4361
const bannedCmd = detectBannedCommand(command)
4462
if (bannedCmd) {
4563
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
4664
}
4765

48-
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
66+
// Only prepend env vars for git commands (editor blocking, pager, etc.)
67+
const isGitCommand = /\bgit\b/.test(command)
68+
if (!isGitCommand) {
69+
return
70+
}
71+
72+
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
73+
// ignoring any args.env we might set. Prepend to command instead.
74+
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
75+
output.args.command = `${envPrefix} ${command}`
76+
77+
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
4978
sessionID: input.sessionID,
50-
env: NON_INTERACTIVE_ENV,
79+
envPrefix,
5180
})
5281
},
5382
}

0 commit comments

Comments
 (0)