mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(mcp): cap execute_code console capture so runaway loops can't OOM the sandbox
execute_code collected console output in an unbounded array. A script whose catch block logs and loops without awaiting (e.g. retrying a method that throws synchronously) never yields, so the execution timeout can't fire and the array grows until the process runs out of memory. Two RuneBench trials (Sonnet 5.5, hitpoints) most likely lost their sandbox this way looping on the removed bot.attackNpc; a surviving run logged the same error 16,210 times. ConsoleCapture keeps the first 300 and last 300 lines, clips lines at 2000 chars, and reports how many lines were omitted, so memory stays constant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af84ba25a5
commit
9dc413c490
3 files changed
+110
-2
No files matched your search
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Bounded console capture for execute_code.
|
||||
*
|
||||
* Captured output used to be an unbounded array. A script that logs from a
|
||||
* tight loop (e.g. a catch block with no await, retrying a call that throws
|
||||
* synchronously) never yields, so the execution timeout can't fire and the
|
||||
* array grows until the process - and the sandbox with it - runs out of
|
||||
* memory. Keep the first `headMax` and last `tailMax` lines, clip long lines,
|
||||
* and count everything dropped in between so memory stays constant.
|
||||
*/
|
||||
|
||||
export const CONSOLE_HEAD_LINES = 300;
|
||||
export const CONSOLE_TAIL_LINES = 300;
|
||||
export const CONSOLE_MAX_LINE_CHARS = 2000;
|
||||
|
||||
export class ConsoleCapture {
|
||||
private readonly head: string[] = [];
|
||||
private readonly tail: string[] = []; // ring buffer once full
|
||||
private tailStart = 0;
|
||||
private omitted = 0;
|
||||
|
||||
constructor(
|
||||
private readonly headMax = CONSOLE_HEAD_LINES,
|
||||
private readonly tailMax = CONSOLE_TAIL_LINES,
|
||||
private readonly maxLineChars = CONSOLE_MAX_LINE_CHARS,
|
||||
) {}
|
||||
|
||||
push(line: string): void {
|
||||
if (line.length > this.maxLineChars) {
|
||||
line = `${line.slice(0, this.maxLineChars)} … [${line.length - this.maxLineChars} more chars]`;
|
||||
}
|
||||
if (this.head.length < this.headMax) {
|
||||
this.head.push(line);
|
||||
} else if (this.tail.length < this.tailMax) {
|
||||
this.tail.push(line);
|
||||
} else if (this.tailMax === 0) {
|
||||
this.omitted++;
|
||||
} else {
|
||||
this.tail[this.tailStart] = line;
|
||||
this.tailStart = (this.tailStart + 1) % this.tailMax;
|
||||
this.omitted++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Number of lines retained (not counting omitted ones). */
|
||||
get length(): number {
|
||||
return this.head.length + this.tail.length;
|
||||
}
|
||||
|
||||
/** Total lines logged, including omitted ones. */
|
||||
get total(): number {
|
||||
return this.length + this.omitted;
|
||||
}
|
||||
|
||||
lines(): string[] {
|
||||
const tail = this.tail.slice(this.tailStart).concat(this.tail.slice(0, this.tailStart));
|
||||
if (this.omitted === 0) return this.head.concat(tail);
|
||||
const marker = `… ${this.omitted} line(s) omitted - console output keeps the first ${this.headMax} and last ${this.tailMax} lines …`;
|
||||
return [...this.head, marker, ...tail];
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -21,6 +21,7 @@ import { formatWorldState } from '../sdk/formatter.js';
|
||||
import { initPathfinding } from '../sdk/pathfinding.js';
|
||||
import { Spells } from '../sdk/spells.js';
|
||||
import { marketTool, readMarket } from './market-tools.js';
|
||||
import { ConsoleCapture } from './console-capture.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -178,7 +179,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
||||
const isLongCode = code.length > 2000;
|
||||
|
||||
// Capture console output BEFORE connecting to prevent SDK logs from corrupting MCP JSON-RPC stdout
|
||||
const logs: string[] = [];
|
||||
const logs = new ConsoleCapture();
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
@@ -287,7 +288,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
||||
|
||||
if (logs.length > 0) {
|
||||
parts.push('── Console ──');
|
||||
parts.push(logs.join('\n'));
|
||||
parts.push(logs.lines().join('\n'));
|
||||
}
|
||||
|
||||
if (result !== undefined) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { ConsoleCapture } from '../../mcp/console-capture';
|
||||
|
||||
describe('ConsoleCapture', () => {
|
||||
test('keeps every line while under the cap', () => {
|
||||
const c = new ConsoleCapture(3, 3);
|
||||
for (const l of ['a', 'b', 'c', 'd']) c.push(l);
|
||||
expect(c.lines()).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(c.total).toBe(4);
|
||||
});
|
||||
|
||||
test('keeps head and most recent tail, marks the gap', () => {
|
||||
const c = new ConsoleCapture(2, 3);
|
||||
for (let i = 1; i <= 10; i++) c.push(`line ${i}`);
|
||||
const lines = c.lines();
|
||||
expect(lines.slice(0, 2)).toEqual(['line 1', 'line 2']);
|
||||
expect(lines[2]).toContain('5 line(s) omitted');
|
||||
expect(lines.slice(3)).toEqual(['line 8', 'line 9', 'line 10']);
|
||||
expect(c.length).toBe(5);
|
||||
expect(c.total).toBe(10);
|
||||
});
|
||||
|
||||
test('clips overlong lines', () => {
|
||||
const c = new ConsoleCapture(5, 5, 10);
|
||||
c.push('x'.repeat(25));
|
||||
expect(c.lines()[0]).toBe(`${'x'.repeat(10)} … [15 more chars]`);
|
||||
});
|
||||
|
||||
test('works with no tail', () => {
|
||||
const c = new ConsoleCapture(1, 0);
|
||||
for (const l of ['a', 'b', 'c']) c.push(l);
|
||||
expect(c.lines()).toEqual(['a', expect.stringContaining('2 line(s) omitted')]);
|
||||
});
|
||||
|
||||
test('stays bounded under a tight synchronous error loop', () => {
|
||||
// The failure mode this guards against: a catch block that logs and
|
||||
// loops without awaiting, so nothing ever drains the buffer.
|
||||
const c = new ConsoleCapture();
|
||||
for (let i = 0; i < 1_000_000; i++) {
|
||||
c.push(`err TypeError: bot.attackNpc is not a function (${i})`);
|
||||
}
|
||||
expect(c.length).toBe(600);
|
||||
expect(c.total).toBe(1_000_000);
|
||||
expect(c.lines().at(-1)).toContain('(999999)');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user