Skip to content

Commit 6429932

Browse files
fix(agent): strip null bytes from spawn args before launching task process (#813)
## Main-trunk migration Replaces #804 after the trunk cutover. The three isolated commits now start from the new `main`; original authorship and the contributor’s implementation are preserved. ## Original PR body ## Problem A task prompt containing a null byte crashes the whole cluster. `spawnTaskProcess` passes the built args array, including the full prompt, straight to `child_process.spawn()`. Node's `normalizeSpawnArguments` throws `TypeError [ERR_INVALID_ARG_VALUE]` on any argv string with an embedded null byte, because argv strings are null terminated at the OS level and simply cannot carry one. ``` TypeError [ERR_INVALID_ARG_VALUE]: The argument 'args[10]' must be a string without null bytes. at normalizeSpawnArguments (node:child_process:584:3) at spawn (node:child_process:780:13) at spawnTaskProcess (src/agent/agent-task-executor.js:833:18) ``` Fixes #621 ## Fix Strip null bytes from string args right before the `spawn()` call in `spawnTaskProcess` (`src/agent/agent-task-executor.js`). This is the one place all task-run args funnel through before reaching the OS, so it covers the prompt and any other string arg without touching prompt-building code upstream. Also exported `spawnTaskProcess` so it can be unit tested directly, same as other internals already exported from this file. ## Test plan - Added `tests/unit/spawn-null-byte-sanitization.test.js`: - stubs `child_process.spawn` and asserts a prompt with an embedded null byte reaches `spawn()` with the null byte stripped - runs the real (unstubbed) `spawn()` with a null-byte-containing arg and asserts it does not throw `ERR_INVALID_ARG_VALUE` - Verified the test fails with `ERR_INVALID_ARG_VALUE` when the fix is reverted, confirming it actually catches the original bug - Full unit suite: 1212 passing, 0 new failures - `npm run lint` / `npm run typecheck` clean on changed files (repo currently has pre-existing, unrelated lint debt on `dev` itself, not introduced by this change) --------- Co-authored-by: Atharv Singh <singhatharv1919@gmail.com>
1 parent 245fd9a commit 6429932

3 files changed

Lines changed: 116 additions & 2 deletions

File tree

src/agent/agent-task-executor.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -875,8 +875,11 @@ function spawnTaskProcess({ agent, ctPath, args, cwd, spawnEnv }) {
875875
// Timeout for spawn phase - if CLI hangs during init (e.g., opencode 429 bug), kill it
876876
const SPAWN_TIMEOUT_MS = 30000; // 30 seconds to spawn task
877877

878+
// spawn() throws on null bytes in argv; strip them before they get there.
879+
const safeArgs = args.map((arg) => (typeof arg === 'string' ? arg.replace(/\0/g, '') : arg));
880+
878881
return new Promise((resolve, reject) => {
879-
const proc = spawn(ctPath, args, {
882+
const proc = spawn(ctPath, safeArgs, {
880883
cwd,
881884
stdio: ['ignore', 'pipe', 'pipe'],
882885
env: spawnEnv,
@@ -2366,6 +2369,7 @@ async function killIsolatedTask(agent, currentTask, taskId, reason, code) {
23662369
module.exports = {
23672370
ensureAskUserQuestionHook,
23682371
spawnClaudeTask,
2372+
spawnTaskProcess,
23692373
followClaudeTaskLogs,
23702374
followClaudeTaskLogsIsolated,
23712375
waitForTaskReady,

src/isolation-manager.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,10 @@ class IsolationManager {
727727

728728
args.push(containerId, ...command);
729729

730-
return spawn('docker', args, {
730+
// spawn() throws on null bytes in argv; strip them before they get there.
731+
const safeArgs = args.map((arg) => (typeof arg === 'string' ? arg.replace(/\0/g, '') : arg));
732+
733+
return spawn('docker', safeArgs, {
731734
stdio: ['pipe', 'pipe', 'pipe'],
732735
...options.spawnOptions,
733736
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
const assert = require('assert');
2+
const sinon = require('sinon');
3+
const { EventEmitter } = require('events');
4+
const childProcess = require('child_process');
5+
6+
// Regression guard for #621: null byte in prompt crashes spawn() with ERR_INVALID_ARG_VALUE.
7+
describe('spawnTaskProcess null byte sanitization', function () {
8+
it('strips null bytes from string args before calling spawn()', function () {
9+
const fakeChild = new EventEmitter();
10+
fakeChild.stdout = new EventEmitter();
11+
fakeChild.stderr = new EventEmitter();
12+
13+
const spawnStub = sinon.stub(childProcess, 'spawn').returns(fakeChild);
14+
try {
15+
const executorPath = require.resolve('../../src/agent/agent-task-executor');
16+
delete require.cache[executorPath];
17+
const { spawnTaskProcess } = require(executorPath);
18+
19+
const dirtyPrompt = 'You are agent "worker".\nDo the task.\0trailing after null byte';
20+
const pending = spawnTaskProcess({
21+
agent: { _log: () => {} },
22+
ctPath: 'zeroshot',
23+
args: ['task', 'run', dirtyPrompt],
24+
cwd: '/tmp',
25+
spawnEnv: {},
26+
});
27+
28+
assert.strictEqual(spawnStub.calledOnce, true);
29+
const spawnedArgs = spawnStub.firstCall.args[1];
30+
for (const arg of spawnedArgs) {
31+
assert.strictEqual(arg.includes('\0'), false, `arg still contains a null byte: ${arg}`);
32+
}
33+
assert.strictEqual(
34+
spawnedArgs[2],
35+
'You are agent "worker".\nDo the task.trailing after null byte'
36+
);
37+
38+
// Resolve the pending promise so it doesn't leak an unhandled rejection.
39+
fakeChild.emit('close', 1);
40+
return assert.rejects(pending);
41+
} finally {
42+
spawnStub.restore();
43+
const executorPath = require.resolve('../../src/agent/agent-task-executor');
44+
delete require.cache[executorPath];
45+
}
46+
});
47+
48+
it('does not throw ERR_INVALID_ARG_VALUE when spawn() runs for real on a null-byte prompt', async function () {
49+
// No stub: exercises the real spawn() to prove the sanitized args are safe.
50+
const executorPath = require.resolve('../../src/agent/agent-task-executor');
51+
delete require.cache[executorPath];
52+
const { spawnTaskProcess } = require(executorPath);
53+
54+
const dirtyPrompt = 'prompt with a null byte \0 in the middle';
55+
const pending = spawnTaskProcess({
56+
agent: { _log: () => {} },
57+
ctPath: process.execPath, // node itself; unrecognized output still exercises the real spawn() call
58+
args: ['--version', dirtyPrompt],
59+
cwd: '/tmp',
60+
spawnEnv: process.env,
61+
});
62+
63+
try {
64+
await pending;
65+
} catch (err) {
66+
assert.notStrictEqual(err.code, 'ERR_INVALID_ARG_VALUE');
67+
assert.doesNotMatch(err.message, /null bytes/);
68+
}
69+
});
70+
});
71+
72+
describe('IsolationManager.spawnInContainer null byte sanitization', function () {
73+
it('strips null bytes from string args before calling spawn()', function () {
74+
// isolation-manager.js's class is prototype-patched by other test files
75+
// (e.g. orchestrator.test.js), which relies on require.cache staying stable
76+
// for the rest of the mocha worker. Snapshot and restore it instead of just
77+
// deleting it, so this test's own reload doesn't leak into other test files.
78+
const managerPath = require.resolve('../../src/isolation-manager');
79+
const originalCacheEntry = require.cache[managerPath];
80+
delete require.cache[managerPath];
81+
82+
const spawnStub = sinon.stub(childProcess, 'spawn').returns(new EventEmitter());
83+
try {
84+
const IsolationManager = require(managerPath);
85+
const manager = new IsolationManager();
86+
manager.containers.set('cluster-1', 'container-abc');
87+
88+
const dirtyPrompt = 'You are agent "worker".\0trailing after null byte';
89+
manager.spawnInContainer('cluster-1', ['zeroshot', 'task', 'run', dirtyPrompt]);
90+
91+
assert.strictEqual(spawnStub.calledOnce, true);
92+
const [bin, spawnedArgs] = spawnStub.firstCall.args;
93+
assert.strictEqual(bin, 'docker');
94+
for (const arg of spawnedArgs) {
95+
assert.strictEqual(arg.includes('\0'), false, `arg still contains a null byte: ${arg}`);
96+
}
97+
assert.strictEqual(spawnedArgs.at(-1), 'You are agent "worker".trailing after null byte');
98+
} finally {
99+
spawnStub.restore();
100+
if (originalCacheEntry) {
101+
require.cache[managerPath] = originalCacheEntry;
102+
} else {
103+
delete require.cache[managerPath];
104+
}
105+
}
106+
});
107+
});

0 commit comments

Comments
 (0)