Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* TASK-266: a native turn that declares commonly_run_code can run code in a
* per-turn sandbox; the sandbox is closed when the turn ends, and its seconds
* are recorded on the AgentRun for the spend alarm.
*/
jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } }));
jest.mock('../../../models/Pod', () => ({
findById: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ name: 'Research' }) })),
}));
jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() }));
jest.mock('../../../models/User', () => ({
findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })),
}));
jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() }));
jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn().mockResolvedValue({ success: true }) }));
jest.mock('../../../services/messageClaimService', () => ({
claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }),
release: jest.fn().mockResolvedValue({ released: true }),
}));
// No outsider-writable grant here, so #2211's narrowing stays off.
jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({
podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false),
}));
const mockRun = jest.fn();
const mockClose = jest.fn();
jest.mock('../../../services/turnSandboxService', () => ({
isSandboxConfigured: () => true,
TurnSandbox: jest.fn().mockImplementation(() => ({ run: mockRun, close: mockClose })),
}));

const axios = require('axios').default;
const AgentRun = require('../../../models/AgentRun');
const { TurnSandbox } = require('../../../services/turnSandboxService');
const { runAgent } = require('../../../services/nativeRuntimeService');

const install = {
podId: '507f1f77bcf86cd799439011',
agentName: 'analyst',
instanceId: 'default',
displayName: 'Analyst',
installedBy: '507f1f77bcf86cd799439099',
config: { runtime: { runtimeType: 'native' }, tools: ['commonly_run_code', 'commonly_post_message'] },
};
const mention = { type: 'chat.mention', eventId: '507f1f77bcf86cd799439012', payload: { content: '@analyst what is 2**10?' } };

const toolCallReply = {
status: 200,
headers: {},
data: {
choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name: 'commonly_run_code', arguments: JSON.stringify({ language: 'python', code: 'print(2**10)' }) } }] } }],
usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 },
},
};
const finalReply = {
status: 200,
headers: {},
data: { choices: [{ message: { role: 'assistant', content: '1024' } }], usage: { prompt_tokens: 12, completion_tokens: 2, total_tokens: 14 } },
};

describe('native turn with commonly_run_code', () => {
let savedRun;
beforeEach(() => {
jest.clearAllMocks();
process.env.LITELLM_BASE_URL = 'http://litellm.test';
process.env.LITELLM_MASTER_KEY = 'test-key';
AgentRun.countDocuments.mockResolvedValue(0);
AgentRun.create.mockImplementation(async (doc) => {
savedRun = { _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) };
return savedRun;
});
mockRun.mockResolvedValue({ ok: true, stdout: '1024\n', stderr: '' });
mockClose.mockResolvedValue(7);
});

test('runs the code, feeds the output back, closes the sandbox and records its seconds', async () => {
axios.post.mockResolvedValueOnce(toolCallReply).mockResolvedValueOnce(finalReply);

const result = await runAgent(install, mention);

expect(result.status).toBe('succeeded');
expect(TurnSandbox).toHaveBeenCalledTimes(1);
expect(mockRun).toHaveBeenCalledWith('python', 'print(2**10)');
const secondCall = axios.post.mock.calls[1][1];
const toolMessage = secondCall.messages.find((m) => m.role === 'tool');
expect(toolMessage.content).toContain('1024');
expect(mockClose).toHaveBeenCalledTimes(1);
expect(savedRun.sandboxSeconds).toBe(7);
});

test('closes the sandbox even when the turn fails after running code', async () => {
axios.post.mockResolvedValueOnce(toolCallReply).mockRejectedValueOnce(new Error('model down'));

await runAgent(install, mention);

expect(mockClose).toHaveBeenCalledTimes(1);
expect(savedRun.sandboxSeconds).toBe(7);
});

test('a turn that runs no code opens no sandbox and records no seconds', async () => {
axios.post.mockResolvedValueOnce(finalReply);
await runAgent(install, mention);
expect(TurnSandbox).not.toHaveBeenCalled();
expect(savedRun.sandboxSeconds).toBeUndefined();
});

// Rhea on #2232: the allowlist holds at dispatch, not only in the offer.
test.each([
['commonly_run_code', JSON.stringify({ language: 'python', code: 'print(1)' })],
['commonly_propose_action', JSON.stringify({ actionType: 'create_pod', summary: 'x', params: { name: 'x' } })],
])('refuses %s when this install never declared it, and runs nothing', async (name, args) => {
const undeclared = { ...install, config: { runtime: { runtimeType: 'native' }, tools: ['commonly_post_message'] } };
axios.post
.mockResolvedValueOnce({
status: 200,
headers: {},
data: {
choices: [{ message: { role: 'assistant', content: null, tool_calls: [{ id: 'c1', type: 'function', function: { name, arguments: args } }] } }],
usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 },
},
})
.mockResolvedValueOnce(finalReply);

await runAgent(undeclared, mention);

expect(TurnSandbox).not.toHaveBeenCalled();
const offered = axios.post.mock.calls[0][1].tools.map((tool) => tool.function.name);
expect(offered).not.toContain(name);
const toolMessage = axios.post.mock.calls[1][1].messages.find((m) => m.role === 'tool');
expect(toolMessage.content).toMatch(/not available to you in this turn/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,21 @@ describe('toolsForConfig', () => {
const schema = tools[0].function.parameters.properties.actionType;
expect(schema.enum.sort()).toEqual(['connect_local_agent', 'create_pod']);
});

// TASK-266: running code is opt-in by declaration AND needs a sandbox key.
test('run_code is offered only when declared and the instance has a sandbox key', () => {
const previous = process.env.E2B_API_KEY;
try {
delete process.env.E2B_API_KEY;
expect(names(toolsForConfig({ tools: ['commonly_run_code'] }))).not.toContain('commonly_run_code');
process.env.E2B_API_KEY = 'e2b_test_key';
expect(names(toolsForConfig({ tools: ['commonly_run_code'] }))).toContain('commonly_run_code');
// Never on the default surface, even with the key.
expect(names(toolsForConfig({}))).not.toContain('commonly_run_code');
expect(names(toolsForConfig({ tools: ['commonly_post_message'] }))).not.toContain('commonly_run_code');
} finally {
if (previous === undefined) delete process.env.E2B_API_KEY;
else process.env.E2B_API_KEY = previous;
}
});
});
46 changes: 45 additions & 1 deletion backend/__tests__/unit/services/spendAlarmService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,50 @@ describe('userSpendForDay', () => {
});
});

describe('sandbox seconds (TASK-266)', () => {
test('are priced per run, once, and alone can cross the line', async () => {
const withSandbox = (userId, seconds, turns) => ({ ...run(userId, turns), sandboxSeconds: seconds });
await AgentRun.create([
// 100,000 s of sandbox and a 2-turn run: priced once, not once per turn.
withSandbox(USER_A, 100_000, [
{ model: 'deepseek-v4-flash', promptTokens: 0, completionTokens: 0 },
{ model: 'deepseek-v4-flash', promptTokens: 0, completionTokens: 0 },
]),
// A run with no turns still costs its sandbox seconds.
withSandbox(USER_B, 10, []),
]);

const rows = await spend.userSpendForDay(NOW);
const a = rows.find((r) => r.userId === String(USER_A));
expect(a.sandboxSeconds).toBe(100_000);
// Literal, worked by hand: 100,000 s x (2 vCPU x $0.000014 + 4 GiB x $0.0000045).
expect(a.usd).toBeCloseTo(4.6, 6);
expect(a.runs).toBe(1);
expect(rows.find((r) => r.userId === String(USER_B)).sandboxSeconds).toBe(10);
});
});

describe('the $2 line with sandbox time in it (literal amounts, independent of the exported rate)', () => {
const alarmLines = (warn) => warn.mock.calls
.map(([line]) => { try { return JSON.parse(line); } catch (_) { return null; } })
.filter((line) => line && line.alarm === 'user_spend');
const sonnetOut = (tokens) => [{ model: 'claude-sonnet-5-5', promptTokens: 0, completionTokens: tokens }];

test.each([
// $1.30 of model output + 20,000 s of sandbox ($0.92) = $2.22: over the line.
[20_000, 2.22, 1],
// $1.30 + 15,000 s ($0.69) = $1.99: under it.
[15_000, 1.99, 0],
])('$1.30 of model spend plus %i sandbox seconds is $%f, %i alarm(s)', async (seconds, usd, alarms) => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
await AgentRun.create({ ...run(USER_A, sonnetOut(130_000)), sandboxSeconds: seconds });
const [row] = await spend.userSpendForDay(NOW);
expect(row.usd).toBeCloseTo(usd, 6);
await spend.checkSpend(NOW);
expect(alarmLines(warn)).toHaveLength(alarms);
});
});

describe('checkSpend', () => {
const alarmLines = (warn) => warn.mock.calls
.map(([line]) => { try { return JSON.parse(line); } catch (_) { return null; } })
Expand All @@ -98,7 +142,7 @@ describe('checkSpend', () => {
const lines = alarmLines(warn);
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({
alarm: 'user_spend', day: '2026-10-15', userId: String(USER_A), usd: 3, thresholdUsd: 2, runs: 1,
alarm: 'user_spend', day: '2026-10-15', userId: String(USER_A), usd: 3, thresholdUsd: 2, runs: 1, sandboxSeconds: 0,
});
});

Expand Down
112 changes: 112 additions & 0 deletions backend/__tests__/unit/services/turnSandboxService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* TASK-266: one E2B sandbox per native turn, no internet, no environment,
* closed when the turn ends, with E2B's lifetime as the backstop.
*/
const mockRunCode = jest.fn();
const mockKill = jest.fn();
const mockCreate = jest.fn();
jest.mock('@e2b/code-interpreter', () => ({
Sandbox: { create: (...args) => mockCreate(...args) },
}));

const { TurnSandbox, MAX_RUNS_PER_TURN, isSandboxConfigured } = require('../../../services/turnSandboxService');

describe('TurnSandbox', () => {
const previousKey = process.env.E2B_API_KEY;

beforeEach(() => {
jest.clearAllMocks();
process.env.E2B_API_KEY = 'e2b_test_key';
mockKill.mockResolvedValue(true);
mockRunCode.mockResolvedValue({ logs: { stdout: ['4\n'], stderr: [] }, text: '4' });
mockCreate.mockResolvedValue({ runCode: mockRunCode, kill: mockKill });
});

afterAll(() => {
if (previousKey === undefined) delete process.env.E2B_API_KEY;
else process.env.E2B_API_KEY = previousKey;
});

test('opens one sandbox for the whole turn, with no internet and no environment', async () => {
const sandbox = new TurnSandbox({ podId: 'pod-1', agent: 'maya-agent:default' });
const first = await sandbox.run('python', 'print(2 + 2)');
await sandbox.run('javascript', 'console.log(1)');

expect(first).toEqual({ ok: true, stdout: '4\n', stderr: '', result: '4' });
expect(mockCreate).toHaveBeenCalledTimes(1);
const [template, options] = mockCreate.mock.calls[0];
expect(template).toBe('code-interpreter-v1'); // pinned: the spend alarm prices this shape
expect(options).toMatchObject({
apiKey: 'e2b_test_key',
allowInternetAccess: false,
network: { allowPublicTraffic: false },
envs: {},
metadata: { podId: 'pod-1' },
});
// Exact, not a subset: toMatchObject treats {} as matching any object, so
// only these pin that nothing from the process env reaches the sandbox
// (sprint-review on #2232).
expect(options.envs).toEqual({});
expect(mockRunCode.mock.calls.every(([, opts]) => Object.keys(opts.envs).length === 0)).toBe(true);
expect(options.timeoutMs).toBeGreaterThan(0);
expect(options.timeoutMs).toBeLessThanOrEqual(5 * 60 * 1000);
expect(mockRunCode).toHaveBeenLastCalledWith('console.log(1)', expect.objectContaining({ language: 'javascript', envs: {} }));
});

test('close kills the sandbox and reports at least one second; a turn that ran no code opens nothing', async () => {
const idle = new TurnSandbox({});
expect(await idle.close()).toBe(0);
expect(mockCreate).not.toHaveBeenCalled();

const used = new TurnSandbox({});
await used.run('python', 'print(1)');
expect(await used.close()).toBeGreaterThanOrEqual(1);
expect(mockKill).toHaveBeenCalledTimes(1);
});

test('a failed kill or a failed create never throws from close', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
mockKill.mockRejectedValueOnce(new Error('network'));
const a = new TurnSandbox({});
await a.run('python', 'print(1)');
await expect(a.close()).resolves.toBeGreaterThanOrEqual(1);

mockCreate.mockRejectedValueOnce(new Error('quota'));
const b = new TurnSandbox({});
await expect(b.run('python', 'print(1)')).rejects.toThrow('quota');
await expect(b.close()).resolves.toBeGreaterThanOrEqual(1);
warn.mockRestore();
});

test('reports a program error and clips long output', async () => {
mockRunCode.mockResolvedValueOnce({
logs: { stdout: ['x'.repeat(10_000)], stderr: [] },
error: { name: 'ZeroDivisionError', value: 'division by zero', traceback: 'line 1' },
});
const result = await new TurnSandbox({}).run('python', '1/0');
expect(result.ok).toBe(false);
expect(result.error).toMatch(/^ZeroDivisionError: division by zero/);
expect(result.stdout.length).toBeLessThan(4200);
expect(result.stdout).toMatch(/more characters\]$/);
});

test('refuses an unknown language, empty code, and runs past the per-turn limit, without opening a sandbox for them', async () => {
const sandbox = new TurnSandbox({});
expect(await sandbox.run('ruby', 'puts 1')).toMatchObject({ ok: false, error: expect.stringMatching(/language/) });
expect(await sandbox.run('python', ' ')).toMatchObject({ ok: false, error: 'code is empty' });
expect(mockCreate).not.toHaveBeenCalled();

for (let i = 0; i < MAX_RUNS_PER_TURN; i += 1) {
// eslint-disable-next-line no-await-in-loop
expect((await sandbox.run('python', 'print(1)')).ok).toBe(true);
}
expect(await sandbox.run('python', 'print(1)')).toMatchObject({ ok: false, error: expect.stringMatching(/code runs/) });
expect(mockRunCode).toHaveBeenCalledTimes(MAX_RUNS_PER_TURN);
});

test('is configured only when the instance has an E2B key', () => {
expect(isSandboxConfigured()).toBe(true);
delete process.env.E2B_API_KEY;
expect(isSandboxConfigured()).toBe(false);
});
});
3 changes: 3 additions & 0 deletions backend/models/AgentRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface IAgentRun extends Document {
status: AgentRunStatus;
turns: IAgentRunTurn[];
totalTokens: number;
/** Seconds the turn's run-code sandbox ran (TASK-266); absent when no code ran. */
sandboxSeconds?: number;
startedAt: Date;
completedAt?: Date;
errorKind?: AgentRunErrorKind;
Expand Down Expand Up @@ -134,6 +136,7 @@ const AgentRunSchema = new Schema<IAgentRun>(
},
turns: { type: [AgentRunTurnSchema], default: [] },
totalTokens: { type: Number, default: 0 },
sandboxSeconds: { type: Number },
startedAt: { type: Date, default: Date.now },
completedAt: { type: Date },
errorKind: {
Expand Down
Loading
Loading