test/devin-acp-stdin-epipe.test.js
Rama master · réplica de lectura del repositorio original.
import { afterEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
runDevinAcpProcess,
__setDevinAcpSpawnForTest,
} from '../src/devin-acp.js';
// Audit finding regression: child.stdin had no 'error' listener. If the Devin
// CLI child dies mid-write, stdin.write raises EPIPE asynchronously as a stdin
// 'error' event; with no listener that becomes an uncaughtException and kills
// the WHOLE proxy (every tenant), not just the one request. These tests drive a
// fake child (EventEmitter stdio) through the spawn seam and assert the handler
// is attached and swallows the error instead of rethrowing.
// A minimal fake child that looks enough like a ChildProcess for makeAcpClient:
// stdout/stderr/stdin are EventEmitters, stdin is writable + captures writes,
// and kill() is a no-op. It never answers RPCs, so the handshake stalls until
// we tear it down — which is exactly the window the guard must survive.
function makeFakeChild() {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
const stdin = new EventEmitter();
stdin.writable = true;
stdin.writes = [];
stdin.write = (chunk) => { stdin.writes.push(String(chunk)); return true; };
child.stdin = stdin;
child.killed = false;
child.kill = () => { child.killed = true; return true; };
return child;
}
afterEach(() => {
__setDevinAcpSpawnForTest(null); // restore the real spawn
});
describe('Devin ACP — stdin EPIPE guard (audit finding)', () => {
it('attaches an stdin error listener as soon as the child is spawned', async () => {
const child = makeFakeChild();
__setDevinAcpSpawnForTest(() => child);
// Kick off the runner; it spawns synchronously then awaits the initialize
// RPC (never answered here). By the next tick the child is constructed and
// its listeners are wired. We abort so the pending promise settles cleanly.
const ac = new AbortController();
const p = runDevinAcpProcess('hi', { modelKey: 'swe-1.6', apiKey: 'k', signal: ac.signal });
p.catch(() => {}); // swallow the eventual abort rejection
await new Promise(r => setImmediate(r));
assert.ok(
child.stdin.listenerCount('error') >= 1,
'stdin must have an error listener so an EPIPE cannot become an uncaughtException',
);
ac.abort();
await assert.rejects(p);
});
it('does not rethrow when stdin emits an EPIPE error (would crash the proxy)', async () => {
const child = makeFakeChild();
__setDevinAcpSpawnForTest(() => child);
const ac = new AbortController();
const p = runDevinAcpProcess('hi', { modelKey: 'swe-1.6', apiKey: 'k', signal: ac.signal });
p.catch(() => {});
await new Promise(r => setImmediate(r));
// Simulate the child dying mid-write: EventEmitter.emit('error') THROWS the
// error if and only if there is no 'error' listener. So a clean emit here is
// itself the proof that our guard is present and swallowed it.
const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' });
assert.doesNotThrow(() => child.stdin.emit('error', epipe));
// A non-EPIPE stdin error must also be swallowed (logged at warn, no throw).
const other = Object.assign(new Error('write EACCES'), { code: 'EACCES' });
assert.doesNotThrow(() => child.stdin.emit('error', other));
ac.abort();
await assert.rejects(p);
});
it('installs a real process-level safety net: no uncaughtException escapes', async () => {
const child = makeFakeChild();
__setDevinAcpSpawnForTest(() => child);
const ac = new AbortController();
const p = runDevinAcpProcess('hi', { modelKey: 'swe-1.6', apiKey: 'k', signal: ac.signal });
p.catch(() => {});
await new Promise(r => setImmediate(r));
let uncaught = null;
const onUncaught = (err) => { uncaught = err; };
process.once('uncaughtException', onUncaught);
try {
child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }));
// Give any (buggy) rethrow a tick to surface as an uncaughtException.
await new Promise(r => setImmediate(r));
assert.equal(uncaught, null, 'stdin EPIPE must not surface as an uncaughtException');
} finally {
process.removeListener('uncaughtException', onUncaught);
}
ac.abort();
await assert.rejects(p);
});
});