Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/env.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Record<string, string> {
return { ...NON_INTERACTIVE_ENV, ...userEnv };
}
18 changes: 11 additions & 7 deletions packages/core/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
77 changes: 77 additions & 0 deletions packages/core/test/effect/cross-spawn-spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}),
)
})
})
42 changes: 42 additions & 0 deletions packages/core/test/env.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
7 changes: 5 additions & 2 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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",
})
Expand Down
Loading