fix(core): ensure robust sandbox cleanup in all process execution paths (#24763)

Co-authored-by: Spencer <spencertang@google.com>
This commit is contained in:
Emily Hedlund
2026-04-08 12:03:36 -07:00
committed by GitHub
parent 3df99d8bcb
commit a39461718c
5 changed files with 412 additions and 362 deletions
+22 -14
View File
@@ -326,6 +326,7 @@ class GrepToolInvocation extends BaseToolInvocation<
let finalCommand = checkCommand;
let finalArgs = checkArgs;
let finalEnv = process.env;
let cleanup: (() => void) | undefined;
if (sandboxManager) {
try {
@@ -338,6 +339,7 @@ class GrepToolInvocation extends BaseToolInvocation<
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanup = prepared.cleanup;
} catch (err) {
debugLogger.debug(
`[GrepTool] Sandbox preparation failed for '${command}':`,
@@ -346,21 +348,27 @@ class GrepToolInvocation extends BaseToolInvocation<
}
}
return await new Promise((resolve) => {
const child = spawn(finalCommand, finalArgs, {
stdio: 'ignore',
shell: true,
env: finalEnv,
try {
return await new Promise((resolve) => {
const child = spawn(finalCommand, finalArgs, {
stdio: 'ignore',
shell: true,
env: finalEnv,
});
child.on('close', (code) => {
resolve(code === 0);
});
child.on('error', (err) => {
debugLogger.debug(
`[GrepTool] Failed to start process for '${command}':`,
err.message,
);
resolve(false);
});
});
child.on('close', (code) => resolve(code === 0));
child.on('error', (err) => {
debugLogger.debug(
`[GrepTool] Failed to start process for '${command}':`,
err.message,
);
resolve(false);
});
});
} finally {
cleanup?.();
}
} catch {
return false;
}
+158 -142
View File
@@ -65,6 +65,7 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
let finalCommand = callCommand;
let finalArgs = args;
let finalEnv = process.env;
let cleanupFunc: (() => void) | undefined;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
@@ -77,58 +78,63 @@ class DiscoveredToolInvocation extends BaseToolInvocation<
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanupFunc = prepared.cleanup;
}
const child = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();
let stdout = '';
let stderr = '';
let error: Error | null = null;
let code: number | null = null;
let signal: NodeJS.Signals | null = null;
await new Promise<void>((resolve) => {
const onStdout = (data: Buffer) => {
stdout += data?.toString();
};
try {
const child = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
child.stdin.write(JSON.stringify(this.params));
child.stdin.end();
const onStderr = (data: Buffer) => {
stderr += data?.toString();
};
await new Promise<void>((resolve) => {
const onStdout = (data: Buffer) => {
stdout += data?.toString();
};
const onError = (err: Error) => {
error = err;
};
const onStderr = (data: Buffer) => {
stderr += data?.toString();
};
const onClose = (
_code: number | null,
_signal: NodeJS.Signals | null,
) => {
code = _code;
signal = _signal;
cleanup();
resolve();
};
const onError = (err: Error) => {
error = err;
};
const cleanup = () => {
child.stdout.removeListener('data', onStdout);
child.stderr.removeListener('data', onStderr);
child.removeListener('error', onError);
child.removeListener('close', onClose);
if (child.connected) {
child.disconnect();
}
};
const onClose = (
_code: number | null,
_signal: NodeJS.Signals | null,
) => {
code = _code;
signal = _signal;
cleanup();
resolve();
};
child.stdout.on('data', onStdout);
child.stderr.on('data', onStderr);
child.on('error', onError);
child.on('close', onClose);
});
const cleanup = () => {
child.stdout.removeListener('data', onStdout);
child.stderr.removeListener('data', onStderr);
child.removeListener('error', onError);
child.removeListener('close', onClose);
if (child.connected) {
child.disconnect();
}
};
child.stdout.on('data', onStdout);
child.stderr.on('data', onStderr);
child.on('error', onError);
child.on('close', onClose);
});
} finally {
cleanupFunc?.();
}
// if there is any error, non-zero exit code, signal, or stderr, return error details instead of stdout
if (error || code !== 0 || signal || stderr) {
@@ -374,6 +380,7 @@ export class ToolRegistry {
.slice(1)
.filter((p): p is string => typeof p === 'string');
let finalEnv = process.env;
let cleanupFunc: (() => void) | undefined;
const sandboxManager = this.config.sandboxManager;
if (sandboxManager) {
@@ -386,118 +393,127 @@ export class ToolRegistry {
finalCommand = prepared.program;
finalArgs = prepared.args;
finalEnv = prepared.env;
cleanupFunc = prepared.cleanup;
}
const proc = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
const stderrDecoder = new StringDecoder('utf8');
let sizeLimitExceeded = false;
const MAX_STDOUT_SIZE = 10 * 1024 * 1024; // 10MB limit
const MAX_STDERR_SIZE = 10 * 1024 * 1024; // 10MB limit
let stdoutByteLength = 0;
let stderrByteLength = 0;
proc.stdout.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stdoutByteLength + data.length > MAX_STDOUT_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stdoutByteLength += data.length;
stdout += stdoutDecoder.write(data);
});
proc.stderr.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stderrByteLength + data.length > MAX_STDERR_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stderrByteLength += data.length;
stderr += stderrDecoder.write(data);
});
await new Promise<void>((resolve, reject) => {
proc.on('error', reject);
proc.on('close', (code) => {
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (sizeLimitExceeded) {
return reject(
new Error(
`Tool discovery command output exceeded size limit of ${MAX_STDOUT_SIZE} bytes.`,
),
);
}
if (code !== 0) {
coreEvents.emitFeedback(
'error',
`Tool discovery command failed with code ${code}.`,
stderr,
);
return reject(
new Error(`Tool discovery command failed with exit code ${code}`),
);
}
resolve();
try {
const proc = spawn(finalCommand, finalArgs, {
env: finalEnv,
});
});
let stdout = '';
const stdoutDecoder = new StringDecoder('utf8');
let stderr = '';
const stderrDecoder = new StringDecoder('utf8');
let sizeLimitExceeded = false;
const MAX_STDOUT_SIZE = 10 * 1024 * 1024; // 10MB limit
const MAX_STDERR_SIZE = 10 * 1024 * 1024; // 10MB limit
// execute discovery command and extract function declarations (w/ or w/o "tool" wrappers)
const functions: FunctionDeclaration[] = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const discoveredItems = JSON.parse(stdout.trim());
let stdoutByteLength = 0;
let stderrByteLength = 0;
if (!discoveredItems || !Array.isArray(discoveredItems)) {
throw new Error(
'Tool discovery command did not return a JSON array of tools.',
);
}
proc.stdout.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stdoutByteLength + data.length > MAX_STDOUT_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stdoutByteLength += data.length;
stdout += stdoutDecoder.write(data);
});
for (const tool of discoveredItems) {
if (tool && typeof tool === 'object') {
if (Array.isArray(tool['function_declarations'])) {
functions.push(...tool['function_declarations']);
} else if (Array.isArray(tool['functionDeclarations'])) {
functions.push(...tool['functionDeclarations']);
} else if (tool['name']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functions.push(tool as FunctionDeclaration);
proc.stderr.on('data', (data) => {
if (sizeLimitExceeded) return;
if (stderrByteLength + data.length > MAX_STDERR_SIZE) {
sizeLimitExceeded = true;
proc.kill();
return;
}
stderrByteLength += data.length;
stderr += stderrDecoder.write(data);
});
await new Promise<void>((resolve, reject) => {
proc.on('error', (err) => {
reject(err);
});
proc.on('close', (code) => {
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (sizeLimitExceeded) {
return reject(
new Error(
`Tool discovery command output exceeded size limit of ${MAX_STDOUT_SIZE} bytes.`,
),
);
}
if (code !== 0) {
coreEvents.emitFeedback(
'error',
`Tool discovery command failed with code ${code}.`,
stderr,
);
return reject(
new Error(
`Tool discovery command failed with exit code ${code}`,
),
);
}
resolve();
});
});
// execute discovery command and extract function declarations (w/ or w/o "tool" wrappers)
const functions: FunctionDeclaration[] = [];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const discoveredItems = JSON.parse(stdout.trim());
if (!discoveredItems || !Array.isArray(discoveredItems)) {
throw new Error(
'Tool discovery command did not return a JSON array of tools.',
);
}
for (const tool of discoveredItems) {
if (tool && typeof tool === 'object') {
if (Array.isArray(tool['function_declarations'])) {
functions.push(...tool['function_declarations']);
} else if (Array.isArray(tool['functionDeclarations'])) {
functions.push(...tool['functionDeclarations']);
} else if (tool['name']) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
functions.push(tool as FunctionDeclaration);
}
}
}
}
// register each function as a tool
for (const func of functions) {
if (!func.name) {
debugLogger.warn('Discovered a tool with no name. Skipping.');
continue;
// register each function as a tool
for (const func of functions) {
if (!func.name) {
debugLogger.warn('Discovered a tool with no name. Skipping.');
continue;
}
const parameters =
func.parametersJsonSchema &&
typeof func.parametersJsonSchema === 'object' &&
!Array.isArray(func.parametersJsonSchema)
? func.parametersJsonSchema
: {};
this.registerTool(
new DiscoveredTool(
this.config,
func.name,
DISCOVERED_TOOL_PREFIX + func.name,
func.description ?? '',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parameters as Record<string, unknown>,
this.messageBus,
),
);
}
const parameters =
func.parametersJsonSchema &&
typeof func.parametersJsonSchema === 'object' &&
!Array.isArray(func.parametersJsonSchema)
? func.parametersJsonSchema
: {};
this.registerTool(
new DiscoveredTool(
this.config,
func.name,
DISCOVERED_TOOL_PREFIX + func.name,
func.description ?? '',
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parameters as Record<string, unknown>,
this.messageBus,
),
);
} finally {
cleanupFunc?.();
}
} catch (e) {
debugLogger.error(`Tool discovery command "${discoveryCmd}" failed:`, e);