diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 6ea9022acf1d..ae927b1b0af4 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -116,15 +116,16 @@ export const make = Effect.gen(function* () { Sink.isSink(x) ? "pipe" : x const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => { - const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true } - if (Predicate.isUndefined(opts.stdin)) return cfg - if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin } - if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin } + // Default stdin to "ignore" to prevent hangs from interactive prompts + const cfg: ChildProcess.StdinConfig = { stream: "ignore", endOnDone: true }; + if (Predicate.isUndefined(opts.stdin)) return cfg; + if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }; + if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }; return { stream: opts.stdin.stream, - encoding: opts.stdin.encoding ?? cfg.encoding, + encoding: opts.stdin.encoding, // Only set encoding if explicitly provided endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone, - } + }; } const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => { diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts new file mode 100644 index 000000000000..6ff0e0d52b01 --- /dev/null +++ b/packages/core/src/env.ts @@ -0,0 +1,23 @@ +// Shared environment constants for non-interactive tool execution +// Used by both core and opencode tool implementations + +/** + * Default environment variables for non-interactive tool execution. + * These prevent prompts and hangs in automated environments. + */ +export const NON_INTERACTIVE_ENV = { + CI: "1", + npm_config_yes: "true", + pnpm_config_yes: "true", + GIT_TERMINAL_PROMPT: "0", + NONINTERACTIVE: "1", + TERM: "dumb", +} as const; + +/** + * Merge non-interactive env with user-provided env, giving precedence to user values. + * This ensures hardening doesn't override explicit user intent. + */ +export function mergeNonInteractiveEnv(userEnv?: Record): Record { + return { ...NON_INTERACTIVE_ENV, ...userEnv }; +} \ No newline at end of file diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 22423764bc67..5e0e7792bbad 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -155,13 +155,17 @@ const layer = Layer.effectDiscard( const shell = Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))) .shell ?? defaultShell() - const command = ChildProcess.make(input.command, [], { - cwd: target.canonical, - shell, - stdin: "ignore", - detached: process.platform !== "win32", - forceKillAfter: Duration.seconds(3), - }) + import { mergeNonInteractiveEnv } from "../../env"; + + const command = ChildProcess.make(input.command, [], { + cwd: target.canonical, + shell, + extendEnv: true, + env: mergeNonInteractiveEnv(), + stdin: "ignore", + detached: process.platform !== "win32", + forceKillAfter: Duration.seconds(3), + }) const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index fc4e3db6a92e..d7fd47ac6c82 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -423,4 +423,81 @@ describe("cross-spawn spawner", () => { }), ) }) + + describe("non-interactive execution", () => { + fx.effect( + "defaults stdin to 'ignore' to prevent hangs", + Effect.gen(function* () { + const handle = yield* js( + 'process.stdin.on("data", () => process.stdout.write("should not receive data")); ' + + 'process.stdin.on("end", () => process.stdout.write("stdin ended")); ' + + 'setTimeout(() => process.stdout.write("timeout"), 100)', + ).pipe( + ChildProcess.withDefaultOptions(), // Uses default stdin: "ignore" + ) + + const out = yield* decodeByteStream(handle.stdout); + yield* handle.exitCode; + expect(out).toContain("stdin ended"); + expect(out).not.toContain("should not receive data"); + }), + ) + + fx.effect( + "respects explicit stdin configuration", + Effect.gen(function* () { + const handle = yield* js( + 'process.stdin.setEncoding("utf8"); let out = ""; ' + + 'process.stdin.on("data", (chunk) => out += chunk); ' + + 'process.stdin.on("end", () => process.stdout.write(out))', + ).pipe( + ChildProcess.withStdin("pipe"), // Explicit stdin overrides default + ) + + yield* handle.stdin.write("test data"); + yield* handle.stdin.end(); + + const out = yield* decodeByteStream(handle.stdout); + yield* handle.exitCode; + expect(out).toBe("test data"); + }), + ) + }) + + describe("environment hardening", () => { + fx.effect( + "includes non-interactive env vars by default", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.platform === "win32" ? "set" : "env", [], { + extendEnv: true, + }), + ), + ) + + expect(out).toContain("CI=1"); + expect(out).toContain("npm_config_yes=true"); + expect(out).toContain("GIT_TERMINAL_PROMPT=0"); + expect(out).toContain("NONINTERACTIVE=1"); + }), + ) + + fx.effect( + "user env takes precedence over defaults", + Effect.gen(function* () { + const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) => + svc.string( + ChildProcess.make(process.platform === "win32" ? "set" : "env", [], { + extendEnv: true, + env: { CI: "0", npm_config_yes: "false" }, // Override defaults + }), + ), + ) + + expect(out).toContain("CI=0"); + expect(out).toContain("npm_config_yes=false"); + }), + ) + }) }) diff --git a/packages/core/test/env.test.ts b/packages/core/test/env.test.ts new file mode 100644 index 000000000000..813b739e0b60 --- /dev/null +++ b/packages/core/test/env.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "bun:test" +import { NON_INTERACTIVE_ENV, mergeNonInteractiveEnv } from "../src/env" + +describe("env", () => { + describe("NON_INTERACTIVE_ENV", () => { + it("should contain all required non-interactive variables", () => { + expect(NON_INTERACTIVE_ENV.CI).toBe("1") + expect(NON_INTERACTIVE_ENV.npm_config_yes).toBe("true") + expect(NON_INTERACTIVE_ENV.pnpm_config_yes).toBe("true") + expect(NON_INTERACTIVE_ENV.GIT_TERMINAL_PROMPT).toBe("0") + expect(NON_INTERACTIVE_ENV.NONINTERACTIVE).toBe("1") + expect(NON_INTERACTIVE_ENV.TERM).toBe("dumb") + }) + }) + + describe("mergeNonInteractiveEnv", () => { + it("should return defaults when no user env provided", () => { + const result = mergeNonInteractiveEnv() + expect(result).toEqual(NON_INTERACTIVE_ENV) + }) + + it("should merge user env with defaults", () => { + const result = mergeNonInteractiveEnv({ CUSTOM_VAR: "value" }) + expect(result.CUSTOM_VAR).toBe("value") + expect(result.CI).toBe("1") + }) + + it("should give precedence to user env over defaults", () => { + const result = mergeNonInteractiveEnv({ CI: "0", npm_config_yes: "false" }) + expect(result.CI).toBe("0") + expect(result.npm_config_yes).toBe("false") + expect(result.GIT_TERMINAL_PROMPT).toBe("0") // From defaults + }) + + it("should not modify the original defaults", () => { + const original = { ...NON_INTERACTIVE_ENV } + mergeNonInteractiveEnv({ CI: "0" }) + expect(NON_INTERACTIVE_ENV.CI).toBe("1") + expect(NON_INTERACTIVE_ENV).toEqual(original) + }) + }) +}) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1e4423e01774..51a55533ed5e 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -291,10 +291,13 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, }) function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) { + // Import shared non-interactive env constants from core + const { mergeNonInteractiveEnv } = require("@opencode/core/env"); + if (process.platform === "win32" && Shell.ps(shell)) { return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { cwd, - env, + env: mergeNonInteractiveEnv(env), // User env takes precedence stdin: "ignore", detached: false, }) @@ -303,7 +306,7 @@ function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv return ChildProcess.make(command, [], { shell, cwd, - env, + env: mergeNonInteractiveEnv(env), // User env takes precedence stdin: "ignore", detached: process.platform !== "win32", })