Skip to content

Commit c7860e1

Browse files
lsr911claude
andcommitted
fix(agents): suppress unhandled stdout/stderr stream errors in sandbox SSH commands
Add no-op error listeners on child stdout/stderr in runSshSandboxCommand and uploadDirectoryToSshTarget to prevent unhandled EventEmitter errors from crashing the process. Matches pattern from sibling helpers. Replace standalone proof scripts with Vitest regression tests that verify error listeners are registered on the production code path. Co-Authored-By: Claude <[email protected]>
1 parent cbf9acf commit c7860e1

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// SSH sandbox stream-error handling tests
2+
import type { ChildProcess, SpawnOptions } from "node:child_process";
3+
import { EventEmitter } from "node:events";
4+
import { PassThrough } from "node:stream";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
const spawnMock = vi.hoisted(() => vi.fn());
8+
9+
type MockChildProcess = EventEmitter & {
10+
stdin: PassThrough;
11+
stdout: PassThrough;
12+
stderr: PassThrough;
13+
kill: ReturnType<typeof vi.fn>;
14+
};
15+
16+
function createMockChildProcess(): MockChildProcess {
17+
const child = new EventEmitter() as MockChildProcess;
18+
child.stdin = new PassThrough();
19+
child.stdout = new PassThrough();
20+
child.stderr = new PassThrough();
21+
child.kill = vi.fn();
22+
return child;
23+
}
24+
25+
vi.mock("node:child_process", async () => {
26+
const actual =
27+
await vi.importActual<typeof import("node:child_process")>(
28+
"node:child_process",
29+
);
30+
return { ...actual, spawn: spawnMock };
31+
});
32+
33+
describe("ssh sandbox stream error handling", () => {
34+
beforeEach(() => {
35+
vi.resetModules();
36+
vi.clearAllMocks();
37+
});
38+
39+
it("runSshSandboxCommand registers error listeners on stdout and stderr", async () => {
40+
const child = createMockChildProcess();
41+
const stdoutOn = vi.spyOn(child.stdout, "on");
42+
const stderrOn = vi.spyOn(child.stderr, "on");
43+
44+
spawnMock.mockImplementationOnce(
45+
(_cmd: string, _args: readonly string[], _opts: SpawnOptions): ChildProcess => {
46+
process.nextTick(() => child.emit("close", 1));
47+
return child as unknown as ChildProcess;
48+
},
49+
);
50+
51+
const { runSshSandboxCommand } = await import("./ssh.js");
52+
53+
try {
54+
await runSshSandboxCommand({
55+
command: "echo test",
56+
target: { host: "localhost", username: "test", connectTimeoutMs: 100 },
57+
allowFailure: true,
58+
});
59+
} catch {
60+
// Expected — we mocked a failing close
61+
}
62+
63+
expect(stdoutOn).toHaveBeenCalledWith("error", expect.any(Function));
64+
expect(stderrOn).toHaveBeenCalledWith("error", expect.any(Function));
65+
});
66+
67+
it("uploadDirectoryToSshTarget registers error listeners on tar and ssh streams", async () => {
68+
const tarChild = createMockChildProcess();
69+
const sshChild = createMockChildProcess();
70+
const tarStderrOn = vi.spyOn(tarChild.stderr, "on");
71+
const sshStdoutOn = vi.spyOn(sshChild.stdout, "on");
72+
const sshStderrOn = vi.spyOn(sshChild.stderr, "on");
73+
74+
spawnMock
75+
.mockImplementationOnce(
76+
(_cmd: string, _args: readonly string[], _opts: SpawnOptions): ChildProcess => {
77+
process.nextTick(() => tarChild.emit("close", 0));
78+
return tarChild as unknown as ChildProcess;
79+
},
80+
)
81+
.mockImplementationOnce(
82+
(_cmd: string, _args: readonly string[], _opts: SpawnOptions): ChildProcess => {
83+
process.nextTick(() => sshChild.emit("close", 0));
84+
return sshChild as unknown as ChildProcess;
85+
},
86+
);
87+
88+
const { uploadDirectoryToSshTarget } = await import("./ssh.js");
89+
90+
try {
91+
await uploadDirectoryToSshTarget({
92+
localPath: "/tmp/test",
93+
remotePath: "/tmp/test",
94+
target: { host: "localhost", username: "test", connectTimeoutMs: 100 },
95+
});
96+
} catch {
97+
// Expected
98+
}
99+
100+
expect(tarStderrOn).toHaveBeenCalledWith("error", expect.any(Function));
101+
expect(sshStdoutOn).toHaveBeenCalledWith("error", expect.any(Function));
102+
expect(sshStderrOn).toHaveBeenCalledWith("error", expect.any(Function));
103+
});
104+
});

src/agents/sandbox/ssh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,9 @@ export async function runSshSandboxCommand(
683683
const stderrChunks: Buffer[] = [];
684684

685685
child.stdout.on("data", (chunk) => stdoutChunks.push(Buffer.from(chunk)));
686+
child.stdout.on("error", () => {});
686687
child.stderr.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk)));
688+
child.stderr.on("error", () => {});
687689
child.on("error", reject);
688690
child.on("close", (code) => {
689691
const stdout = Buffer.concat(stdoutChunks);
@@ -792,8 +794,11 @@ export async function uploadDirectoryToSshTarget(params: {
792794
let sshCode = 0;
793795

794796
tar.stderr.on("data", (chunk) => tarStderr.push(Buffer.from(chunk)));
797+
tar.stderr.on("error", () => {});
795798
ssh.stdout.on("data", (chunk) => sshStdout.push(Buffer.from(chunk)));
799+
ssh.stdout.on("error", () => {});
796800
ssh.stderr.on("data", (chunk) => sshStderr.push(Buffer.from(chunk)));
801+
ssh.stderr.on("error", () => {});
797802

798803
const fail = (error: unknown) => {
799804
tar.kill("SIGKILL");

0 commit comments

Comments
 (0)