Files
oh-my-pi/packages/coding-agent/test/bash-executor.test.ts
T
can1357 76a921f284 fix: apply non-interactive env to all bash execution paths
The non-interactive environment variables (pager, editor, and prompt
suppression) were only applied in the PTY (interactive) bash path.
The non-PTY executeBash path had no such defaults, so commands could
block on pagers or credential prompts.

- Extract NO_PAGER_ENV from bash-interactive.ts into shared
  non-interactive-env.ts module (renamed to NON_INTERACTIVE_ENV)
- Apply it in executeBash (non-PTY) with user overrides taking
  precedence
- Fix ordering in interactive PTY path so user env overrides
  the defaults (was reversed)
- Add test verifying defaults are applied alongside user env
2026-02-24 02:57:43 +01:00

334 lines
9.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-"));
}
describe("executeBash", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = makeTempDir();
_resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir });
});
afterEach(() => {
_resetSettingsForTest();
vi.restoreAllMocks();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
it("returns non-zero exit codes without cancellation", async () => {
const result = await executeBash("exit 7", { cwd: tempDir, timeout: 5000 });
expect(result.exitCode).toBe(7);
expect(result.cancelled).toBe(false);
});
it("honors cwd", async () => {
const result = await executeBash("pwd", { cwd: tempDir, timeout: 5000 });
expect(result.output.trim()).toBe(tempDir);
});
it("passes env vars", async () => {
const result = await executeBash("echo $PI_TEST_ENV", {
cwd: tempDir,
timeout: 5000,
env: { PI_TEST_ENV: "hello" },
});
expect(result.output.trim()).toBe("hello");
});
it("applies non-interactive environment defaults", async () => {
const result = await executeBash('echo "$GIT_TERMINAL_PROMPT:$PI_TEST_ENV"', {
cwd: tempDir,
timeout: 5000,
env: { PI_TEST_ENV: "hello" },
});
expect(result.output.trim()).toBe("0:hello");
});
it("invokes onChunk with command output", async () => {
let seenChunk: string | null = null;
const result = await executeBash("echo hello", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
if (seenChunk === null) {
seenChunk = chunk;
}
},
});
expect(result.output.trim()).toBe("hello");
expect(seenChunk).not.toBeNull();
expect(seenChunk ?? "").toContain("hello");
});
it("returns even if command spawns a background job", async () => {
if (process.platform === "win32") {
return;
}
const start = Date.now();
const result = await executeBash("{ sleep 5; } & echo fg", {
cwd: tempDir,
timeout: 5000,
});
expect(result.output).toContain("fg");
expect(Date.now() - start).toBeLessThan(3000);
});
it("times out commands", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 10", { cwd: tempDir, timeout: 50 });
expect(result.cancelled).toBe(true);
expect(result.output).toContain("timed out");
});
it("times out before follow-up output", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 10; echo done", { cwd: tempDir, timeout: 50 });
expect(result.cancelled).toBe(true);
expect(result.output).toContain("timed out");
expect(result.output).not.toContain("done");
});
it("aborts commands", async () => {
if (process.platform === "win32") {
return;
}
const controller = new AbortController();
const promise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
});
await Bun.sleep(50);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
});
it("aborts before follow-up output", async () => {
if (process.platform === "win32") {
return;
}
const controller = new AbortController();
const promise = executeBash("sleep 10; echo done", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
});
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
expect(result.output).not.toContain("done");
});
it("resets persistent session state after abort", async () => {
if (process.platform === "win32") {
return;
}
const sessionKey = "reset-on-abort";
await executeBash("export PI_RESET_VAR=alive", { cwd: tempDir, timeout: 5000, sessionKey });
const beforeAbort = await executeBash("echo $PI_RESET_VAR", { cwd: tempDir, timeout: 5000, sessionKey });
expect(beforeAbort.output.trim()).toBe("alive");
const controller = new AbortController();
const abortPromise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
sessionKey,
});
await Bun.sleep(50);
controller.abort();
const aborted = await abortPromise;
expect(aborted.cancelled).toBe(true);
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is a bash variable expansion
const afterAbort = await executeBash("echo ${PI_RESET_VAR:-unset}", {
cwd: tempDir,
timeout: 5000,
sessionKey,
});
expect(afterAbort.output.trim()).toBe("unset");
});
it("streams output chunks", async () => {
const chunks: string[] = [];
const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
expect(chunk.length).toBeGreaterThan(0);
chunks.push(chunk);
},
});
const combined = chunks.join("");
expect(chunks.length).toBeGreaterThan(0);
expect(combined).toContain("line1");
expect(combined).toContain("line20");
expect(result.output).toContain("line1");
expect(result.output).toContain("line20");
});
it("streams large output without exhausting memory", async () => {
if (process.platform === "win32") {
return;
}
let totalBytes = 0;
let sawChunk = false;
const result = await executeBash("awk 'BEGIN { for (i = 0; i < 100000; i++) printf \"a\" }'", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
sawChunk = true;
totalBytes += Buffer.byteLength(chunk, "utf-8");
},
});
expect(sawChunk).toBe(true);
expect(totalBytes).toBe(100000);
expect(result.totalBytes).toBe(100000);
expect(result.outputBytes).toBeLessThanOrEqual(DEFAULT_MAX_BYTES);
expect(result.output).toContain("a");
});
it("sources snapshot env vars across session commands", async () => {
if (process.platform === "win32") {
return;
}
const bashPath = Bun.env.SHELL?.includes("bash") ? Bun.env.SHELL : "/bin/bash";
if (!fs.existsSync(bashPath)) {
return;
}
const snapshotPath = path.join(tempDir, "snapshot.sh");
fs.writeFileSync(snapshotPath, "export PI_SNAPSHOT_TEST=from_snapshot\n");
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: bashPath,
args: ["-l", "-c"],
env: {
PATH: Bun.env.PATH ?? "",
HOME: Bun.env.HOME ?? tempDir,
},
prefix: undefined,
});
vi.spyOn(shellSnapshot, "getOrCreateSnapshot").mockResolvedValue(snapshotPath);
const sessionKey = "snapshot-test";
await executeBash("true", { cwd: tempDir, timeout: 5000, sessionKey });
const result = await executeBash("echo $PI_SNAPSHOT_TEST", { cwd: tempDir, timeout: 5000, sessionKey });
expect(result.output.trim()).toBe("from_snapshot");
});
it("does not allow exec to replace the host", async () => {
const result = await executeBash("exec echo hi", { cwd: tempDir, timeout: 5000 });
expect(result.cancelled).toBe(false);
expect(result.exitCode).not.toBeUndefined();
if (!result.output.includes("hi")) {
expect(result.output.toLowerCase()).toContain("exec");
}
});
it("kills spawned process on timeout (not just orphans it)", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker.txt");
// Command creates marker after 2s, but we timeout after 100ms
const result = await executeBash(`sleep 2 && echo done > ${marker}`, {
cwd: tempDir,
timeout: 100,
});
expect(result.cancelled).toBe(true);
// Wait longer than the command would have taken
await Bun.sleep(3000);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
});
it("kills background jobs on timeout", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker-bg.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const result = await executeBash(`{ sleep 2; echo done > '${markerEscaped}'; } & sleep 10`, {
cwd: tempDir,
timeout: 100,
});
expect(result.cancelled).toBe(true);
await Bun.sleep(3000);
expect(fs.existsSync(marker)).toBe(false);
});
it("kills background jobs on abort", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker-bg-abort.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const controller = new AbortController();
const promise = executeBash(`{ sleep 2; echo done > '${markerEscaped}'; } & sleep 10`, {
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
});
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
await Bun.sleep(3000);
expect(fs.existsSync(marker)).toBe(false);
});
it("kills spawned process on abort (not just orphans it)", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker.txt");
const controller = new AbortController();
// Command creates marker after 2s
const promise = executeBash(`sleep 2 && echo done > ${marker}`, {
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
});
// Abort after 100ms
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
// Wait longer than the command would have taken
await Bun.sleep(3000);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
});
});