From 5ec208598a7e2dd8e5787db96b584fc54fd86544 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:30:11 -0700 Subject: [PATCH 1/5] feat(native): commonly_run_code runs a program in a per-turn E2B sandbox with no internet (TASK-266) Stage 2 of the out-of-the-box plan (ADR-023 amendment A2). A native turn that declares commonly_run_code can run a short Python or JavaScript program and read what it printed. One E2B sandbox per turn: opened on the first call, killed when the turn ends whatever happened, with E2B's 3-minute lifetime as the backstop if the kill fails. - No internet (allowInternetAccess: false): the model holds pod content, so outbound access would make an injected instruction an exfiltration channel. - No environment variables, so no secret is inside it. - At most 5 runs per turn, 30 s each, output clipped. - Opt-in: offered only to installs that declare it AND when the instance has E2B_API_KEY; never on the default tool surface. - The sandbox's seconds are recorded on the AgentRun (sandboxSeconds) for the spend alarm, since E2B has no spending cap. Helm: hostedSandbox.enabled gates the api-keys entry (commonly-dev-e2b-api-key) and the backend E2B_API_KEY env; off by default, on in values-dev. Dependency: @e2b/code-interpreter 2.9.0 (MIT, no install scripts). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- .../nativeRuntimeService.runCode.test.js | 101 ++++++ .../nativeRuntimeService.toolGating.test.js | 17 + .../unit/services/turnSandboxService.test.js | 102 ++++++ backend/models/AgentRun.ts | 3 + backend/package-lock.json | 323 +++++++++++++++++- backend/package.json | 1 + backend/services/nativeRuntimeService.ts | 46 ++- backend/services/turnSandboxService.ts | 120 +++++++ .../templates/core/backend-deployment.yaml | 10 + .../commonly/templates/secrets/api-keys.yaml | 8 + k8s/helm/commonly/values-dev.yaml | 6 + k8s/helm/commonly/values.yaml | 6 + 12 files changed, 735 insertions(+), 8 deletions(-) create mode 100644 backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js create mode 100644 backend/__tests__/unit/services/turnSandboxService.test.js create mode 100644 backend/services/turnSandboxService.ts diff --git a/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js new file mode 100644 index 0000000000..506d318596 --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js @@ -0,0 +1,101 @@ +/** + * 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 }), +})); +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(); + }); +}); diff --git a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js index c7b8a6f50c..7e14b2b4e5 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.toolGating.test.js @@ -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; + } + }); }); diff --git a/backend/__tests__/unit/services/turnSandboxService.test.js b/backend/__tests__/unit/services/turnSandboxService.test.js new file mode 100644 index 0000000000..820fdcc0dd --- /dev/null +++ b/backend/__tests__/unit/services/turnSandboxService.test.js @@ -0,0 +1,102 @@ +/** + * 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 [options] = mockCreate.mock.calls[0]; + expect(options).toMatchObject({ + apiKey: 'e2b_test_key', allowInternetAccess: false, envs: {}, metadata: { podId: 'pod-1' }, + }); + 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); + }); +}); diff --git a/backend/models/AgentRun.ts b/backend/models/AgentRun.ts index c8bef0e1b5..8ae0bacb98 100644 --- a/backend/models/AgentRun.ts +++ b/backend/models/AgentRun.ts @@ -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; @@ -134,6 +136,7 @@ const AgentRunSchema = new Schema( }, turns: { type: [AgentRunTurnSchema], default: [] }, totalTokens: { type: Number, default: 0 }, + sandboxSeconds: { type: Number }, startedAt: { type: Date, default: Date.now }, completedAt: { type: Date }, errorKind: { diff --git a/backend/package-lock.json b/backend/package-lock.json index 36c573d91f..d258344dac 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.1.0", "license": "Apache-2.0", "dependencies": { + "@e2b/code-interpreter": "2.9.0", "@google-cloud/secret-manager": "^6.1.1", "@google/generative-ai": "^0.2.1", "@kubernetes/client-node": "^0.21.0", @@ -660,6 +661,31 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.2.0.tgz", + "integrity": "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0" + } + }, + "node_modules/@connectrpc/connect-web": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.2.0.tgz", + "integrity": "sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^2.7.0", + "@connectrpc/connect": "2.2.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -803,6 +829,27 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@e2b/code-interpreter": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-2.9.0.tgz", + "integrity": "sha512-ZLO3ayZ0N5kMbKFtgfMSiTa3ppzkybEZgI89XJa55gTv+AvuddO17oFUioeJty3aefhVOnMYiALWfCSxeCKhVA==", + "license": "MIT", + "dependencies": { + "e2b": "^2.56.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@e2b/dockerfile-utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@e2b/dockerfile-utils/-/dockerfile-utils-0.1.1.tgz", + "integrity": "sha512-eDmlWRMrJNzSUbBOf6Y/qTChd1Ys/o3YY8PK6tRQmwkV2u7z3ZS2cak/YJB07odHMT/Z6HJKoUElR/b0N6bHUw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -4839,6 +4886,12 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "license": "MIT" + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -5249,6 +5302,118 @@ "node": ">= 6" } }, + "node_modules/e2b": { + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.56.0.tgz", + "integrity": "sha512-67vAYTERArdKrPujKnAVZuiIKLFX3vQT2gh1+TFphwEmYcXfU2iGKraRxpwn9qWIdUPFVym+KOo+QFhnySWYNQ==", + "license": "MIT", + "dependencies": { + "@bufbuild/protobuf": "^2.15.0", + "@connectrpc/connect": "^2.2.0", + "@connectrpc/connect-web": "^2.2.0", + "@e2b/dockerfile-utils": "^0.1.1", + "chalk": "^5.6.2", + "compare-versions": "^6.1.1", + "glob": "^13.0.6", + "openapi-fetch": "^0.17.0", + "platform": "^1.3.6", + "tar": "^7.5.22", + "undici": "^8.11.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/e2b/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/e2b/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/e2b/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/e2b/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/e2b/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/e2b/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/e2b/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -9116,10 +9281,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -10032,6 +10197,21 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -10466,6 +10646,12 @@ "node": ">=8" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13551,6 +13737,23 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==" + }, + "@connectrpc/connect": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-2.2.0.tgz", + "integrity": "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==", + "requires": {} + }, + "@connectrpc/connect-web": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-web/-/connect-web-2.2.0.tgz", + "integrity": "sha512-uuBUcBTYfb/r1o98LzCCZk1H90yx0vmZ5m21dpw/q4EUwsKwkCDGI8H0aEdjcZld+Q2jJ5A5ADvPqfb6lzQ/sg==", + "requires": {} + }, "@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -13650,6 +13853,19 @@ } } }, + "@e2b/code-interpreter": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/@e2b/code-interpreter/-/code-interpreter-2.9.0.tgz", + "integrity": "sha512-ZLO3ayZ0N5kMbKFtgfMSiTa3ppzkybEZgI89XJa55gTv+AvuddO17oFUioeJty3aefhVOnMYiALWfCSxeCKhVA==", + "requires": { + "e2b": "^2.56.0" + } + }, + "@e2b/dockerfile-utils": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@e2b/dockerfile-utils/-/dockerfile-utils-0.1.1.tgz", + "integrity": "sha512-eDmlWRMrJNzSUbBOf6Y/qTChd1Ys/o3YY8PK6tRQmwkV2u7z3ZS2cak/YJB07odHMT/Z6HJKoUElR/b0N6bHUw==" + }, "@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -16460,6 +16676,11 @@ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, + "compare-versions": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==" + }, "component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -16744,6 +16965,76 @@ } } }, + "e2b": { + "version": "2.56.0", + "resolved": "https://registry.npmjs.org/e2b/-/e2b-2.56.0.tgz", + "integrity": "sha512-67vAYTERArdKrPujKnAVZuiIKLFX3vQT2gh1+TFphwEmYcXfU2iGKraRxpwn9qWIdUPFVym+KOo+QFhnySWYNQ==", + "requires": { + "@bufbuild/protobuf": "^2.15.0", + "@connectrpc/connect": "^2.2.0", + "@connectrpc/connect-web": "^2.2.0", + "@e2b/dockerfile-utils": "^0.1.1", + "chalk": "^5.6.2", + "compare-versions": "^6.1.1", + "glob": "^13.0.6", + "openapi-fetch": "^0.17.0", + "platform": "^1.3.6", + "tar": "7.5.22", + "undici": "6.29.0" + }, + "dependencies": { + "balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "requires": { + "balanced-match": "^4.0.2" + } + }, + "chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" + }, + "glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "requires": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + } + }, + "lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==" + }, + "minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "requires": { + "brace-expansion": "^5.0.8" + } + }, + "path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "requires": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + } + } + } + }, "eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -19482,9 +19773,9 @@ "dev": true }, "minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" }, "minizlib": { "version": "3.1.0", @@ -20056,6 +20347,19 @@ } } }, + "openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "requires": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==" + }, "openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -20345,6 +20649,11 @@ "find-up": "^4.0.0" } }, + "platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==" + }, "possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index e853d4c412..c3f695c2e8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -33,6 +33,7 @@ "tsc:check:all": "tsc --noEmit" }, "dependencies": { + "@e2b/code-interpreter": "2.9.0", "@google-cloud/secret-manager": "^6.1.1", "@google/generative-ai": "^0.2.1", "@kubernetes/client-node": "^0.21.0", diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 761d6c33ec..0df03e000d 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -32,6 +32,7 @@ import { HostedBrokerProjection, } from './grantBrokerProjectionService'; import { podHasOutsiderWritableGrant } from './nativeOutsiderGrantPolicy'; +import { isSandboxConfigured, TurnSandbox } from './turnSandboxService'; import { untrustedConnectorMarker } from './brokerResultMarker'; // --- public surface -------------------------------------------------------- @@ -256,8 +257,33 @@ export const TOOLS = [ parameters: { type: 'object', properties: {} }, }, }, + { + type: 'function', + function: { + name: 'commonly_run_code', + description: + 'Run a short Python or JavaScript program in a fresh, isolated sandbox and get back ' + + 'what it printed. Use it to calculate, transform data, or check something by running ' + + 'it rather than guessing. The sandbox has no internet access and none of the pod\'s ' + + 'files, it is discarded when this turn ends, and each run stops after 30 seconds. ' + + 'Print the values you want to see.', + parameters: { + type: 'object', + properties: { + language: { type: 'string', enum: ['python', 'javascript'] }, + code: { type: 'string', description: 'The program to run.' }, + }, + required: ['language', 'code'], + }, + }, + }, ] as const; +// Tools an install gets only by declaring them: proposing an approval, and +// running code (TASK-266), which also needs a sandbox key on the instance. +const OPT_IN_TOOLS: ReadonlySet = new Set(['commonly_propose_action', 'commonly_run_code']); +const toolAvailable = (name: string): boolean => name !== 'commonly_run_code' || isSandboxConfigured(); + // ADR-020 D1: the manifest's `tools` list is the capability boundary, and it // must be ENFORCED here, not just declared. Before this filter, TOOLS went // unfiltered to every native agent — the allowlist in each agent definition @@ -273,7 +299,8 @@ export const toolsForConfig = ( const declared = Array.isArray(cfg?.tools) ? (cfg?.tools as string[]) : null; return TOOLS.filter((t) => (declared ? declared.includes(t.function.name) - : t.function.name !== 'commonly_propose_action') + : !OPT_IN_TOOLS.has(t.function.name)) + && toolAvailable(t.function.name) && (!inPodOnly || IN_POD_NATIVE_TOOLS.has(t.function.name))); }; @@ -287,6 +314,8 @@ interface DispatchContext { displayName: string; installationConfig: PlainConfig; inPodOnly?: boolean; + /** Opened on the turn's first commonly_run_code call; closed when the turn ends. */ + sandbox?: TurnSandbox; } interface DispatchResult { @@ -431,6 +460,15 @@ async function dispatchTool( }; } + case 'commonly_run_code': { + if (!isSandboxConfigured()) { + return { content: null, error: 'Running code is not available on this instance.' }; + } + ctx.sandbox = ctx.sandbox || new TurnSandbox({ podId: ctx.podId, agent: `${ctx.agentName}:${ctx.instanceId}` }); + const result = await ctx.sandbox.run(String(args.language || ''), String(args.code ?? '')); + return { content: result }; + } + case 'commonly_agent_status': { // One derivation, shared with the pod-agents roster route — the // Raft-comparison P4 rule: never fork status vocabulary per surface. @@ -1295,6 +1333,12 @@ export async function runAgent( run.errorMessage = (loopErr as Error).message?.slice(0, 1000) || 'unknown error'; } + // TASK-266: the sandbox lives for one turn. Close it whatever happened, and + // record how long it ran: E2B has no spending cap, so the spend alarm prices it. + if (dispatchCtx.sandbox) { + run.sandboxSeconds = await dispatchCtx.sandbox.close(); + } + run.completedAt = new Date(); try { await run.save(); diff --git a/backend/services/turnSandboxService.ts b/backend/services/turnSandboxService.ts new file mode 100644 index 0000000000..c37b1dd28f --- /dev/null +++ b/backend/services/turnSandboxService.ts @@ -0,0 +1,120 @@ +/** + * The run-code sandbox for native turns (TASK-266, ADR-023 amendment A2). + * + * One E2B sandbox per turn: created on the turn's first `commonly_run_code` + * call and killed when the turn ends, whatever the outcome. + * + * - No internet (`allowInternetAccess: false`). The model writing this code + * holds pod content, so outbound access would turn any injected instruction + * into an exfiltration channel. + * - No environment variables, so no secret is ever inside it. + * - E2B's own sandbox lifetime (SANDBOX_LIFETIME_MS) is the backstop: if the + * kill fails, the sandbox stops itself, and its billed seconds stay bounded. + * E2B has no spending cap, so the per-user spend alarm (TASK-263) prices the + * seconds recorded on the AgentRun. + */ + +const SANDBOX_LIFETIME_MS = 3 * 60 * 1000; +const RUN_TIMEOUT_MS = 30_000; +const REQUEST_TIMEOUT_MS = 45_000; +const MAX_CODE_CHARS = 20_000; +const STDOUT_LIMIT = 4000; +const STDERR_LIMIT = 2000; +export const MAX_RUNS_PER_TURN = 5; +export const RUN_CODE_LANGUAGES: readonly string[] = ['python', 'javascript']; + +export const isSandboxConfigured = (): boolean => Boolean(process.env.E2B_API_KEY); + +// The slice of @e2b/code-interpreter this service uses, typed locally so the +// module is loaded only when a turn actually runs code. +interface E2BExecution { + logs: { stdout: string[]; stderr: string[] }; + error?: { name: string; value: string; traceback: string }; + text?: string; +} +interface E2BSandbox { + runCode(code: string, opts: Record): Promise; + kill(): Promise; +} + +export interface RunCodeResult { + ok: boolean; + stdout: string; + stderr: string; + result?: string; + error?: string; +} + +const clip = (text: string, limit: number): string => ( + text.length > limit ? `${text.slice(0, limit)}\n… [${text.length - limit} more characters]` : text +); + +export class TurnSandbox { + private creating: Promise | null = null; + + private startedAt = 0; + + private runs = 0; + + private readonly metadata: Record; + + constructor(metadata: Record) { + this.metadata = metadata; + } + + private open(): Promise { + if (!this.creating) { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { Sandbox } = require('@e2b/code-interpreter'); + this.startedAt = Date.now(); + this.creating = Sandbox.create({ + apiKey: process.env.E2B_API_KEY, + timeoutMs: SANDBOX_LIFETIME_MS, + allowInternetAccess: false, + envs: {}, + metadata: this.metadata, + }) as Promise; + } + return this.creating; + } + + async run(language: string, code: string): Promise { + const refuse = (error: string): RunCodeResult => ({ ok: false, stdout: '', stderr: '', error }); + if (!RUN_CODE_LANGUAGES.includes(language)) return refuse(`language must be one of: ${RUN_CODE_LANGUAGES.join(', ')}`); + if (!code.trim()) return refuse('code is empty'); + if (code.length > MAX_CODE_CHARS) return refuse(`code is longer than ${MAX_CODE_CHARS} characters`); + if (this.runs >= MAX_RUNS_PER_TURN) return refuse(`this turn has used its ${MAX_RUNS_PER_TURN} code runs`); + this.runs += 1; + + const sandbox = await this.open(); + const execution = await sandbox.runCode(code, { + language, timeoutMs: RUN_TIMEOUT_MS, requestTimeoutMs: REQUEST_TIMEOUT_MS, envs: {}, + }); + return { + ok: !execution.error, + stdout: clip((execution.logs?.stdout || []).join(''), STDOUT_LIMIT), + stderr: clip((execution.logs?.stderr || []).join(''), STDERR_LIMIT), + ...(execution.text ? { result: clip(execution.text, STDOUT_LIMIT) } : {}), + ...(execution.error ? { + error: clip(`${execution.error.name}: ${execution.error.value}\n${execution.error.traceback || ''}`, STDERR_LIMIT), + } : {}), + }; + } + + /** + * Kills the sandbox if this turn opened one, and returns the seconds it ran + * (0 when no code ran). Never throws: a failed kill is logged, and E2B stops + * the sandbox at its lifetime. + */ + async close(): Promise { + if (!this.creating) return 0; + const seconds = Math.max(1, Math.ceil((Date.now() - this.startedAt) / 1000)); + try { + const sandbox = await this.creating; + await sandbox.kill(); + } catch (error) { + console.warn('[sandbox] close failed; E2B stops it at its lifetime:', (error as Error).message); + } + return seconds; + } +} diff --git a/k8s/helm/commonly/templates/core/backend-deployment.yaml b/k8s/helm/commonly/templates/core/backend-deployment.yaml index 35983e5425..6ca9999081 100644 --- a/k8s/helm/commonly/templates/core/backend-deployment.yaml +++ b/k8s/helm/commonly/templates/core/backend-deployment.yaml @@ -76,6 +76,16 @@ spec: - name: SENTRY_RELEASE value: {{ .Values.backend.image.tag | quote }} {{- end }} + {{- if .Values.hostedSandbox.enabled }} + # Native turns that declare commonly_run_code run it in an E2B sandbox + # (services/turnSandboxService.ts). Unset, the tool is never offered. + - name: E2B_API_KEY + valueFrom: + secretKeyRef: + name: api-keys + key: e2b-api-key + optional: true + {{- end }} {{- if .Values.backend.env.registrationInviteOnly }} - name: REGISTRATION_INVITE_ONLY value: {{ .Values.backend.env.registrationInviteOnly | quote }} diff --git a/k8s/helm/commonly/templates/secrets/api-keys.yaml b/k8s/helm/commonly/templates/secrets/api-keys.yaml index 0a587b1758..18261ffef5 100644 --- a/k8s/helm/commonly/templates/secrets/api-keys.yaml +++ b/k8s/helm/commonly/templates/secrets/api-keys.yaml @@ -43,6 +43,14 @@ spec: key: commonly-dev-sentry-dsn {{- end }} + {{- if .Values.hostedSandbox.enabled }} + # The run-code sandbox for native turns (E2B, TASK-266). Same gating rule as + # error tracking: the remote key must exist with a version before this flips. + - secretKey: e2b-api-key + remoteRef: + key: commonly-dev-e2b-api-key + {{- end }} + {{- if .Values.billing.enabled }} # Billing (Stripe). Same gating rule as error tracking above: ESO rejects the # entire ExternalSecret when any referenced remote key is missing, so all diff --git a/k8s/helm/commonly/values-dev.yaml b/k8s/helm/commonly/values-dev.yaml index 69bb914cd1..dc79889f13 100644 --- a/k8s/helm/commonly/values-dev.yaml +++ b/k8s/helm/commonly/values-dev.yaml @@ -8,6 +8,12 @@ global: errorTracking: enabled: true +# commonly-dev-e2b-api-key has one enabled version (Cofounder, 2026-10-15). +# Tools stay opt-in per install, so this offers code runs to nobody until an +# agent declares commonly_run_code. +hostedSandbox: + enabled: true + # All three commonly-dev-stripe-* secrets verified present with an ENABLED # version on 2026-08-06 before this was flipped. Do not set true anywhere the # secrets are absent: an empty secret container passes `describe` and still diff --git a/k8s/helm/commonly/values.yaml b/k8s/helm/commonly/values.yaml index 772289d3bd..fd9afeb0e1 100644 --- a/k8s/helm/commonly/values.yaml +++ b/k8s/helm/commonly/values.yaml @@ -18,6 +18,12 @@ createNamespace: true errorTracking: enabled: false +# The run-code sandbox for native turns (E2B, TASK-266). Off by default: on, +# the api-keys ExternalSecret references commonly-dev-e2b-api-key, and ESO +# rejects the whole secret if that key is missing. +hostedSandbox: + enabled: false + # Paid tier (Stripe). Opt-in, and OFF by default so self-hosted deployments # never reach for billing credentials they do not have. # From afee8e6c1f0b61ff01dd8352af9b2d35f6911891 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:46:20 -0700 Subject: [PATCH 2/5] fix(native): the tool allowlist holds at dispatch, and sandbox URLs are not public Rhea's HOLD on #2232 (5535722222): 1. The allowlist was enforced only in what the model was offered: a call to a tool it was never given still dispatched, so an undeclared commonly_run_code (or commonly_propose_action, already on main) ran. The loop now records the names sent in each model call and refuses anything else, telling the model why. This also covers a tool an in-turn narrowing removes. 2. E2B serves sandbox ports on public URLs by default. The sandbox now sets network.allowPublicTraffic: false, so a URL needs the traffic token only this process holds; the code-interpreter client sends it on every call. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- .../nativeRuntimeService.runCode.test.js | 26 +++++++++++++++++++ .../unit/services/turnSandboxService.test.js | 6 ++++- backend/services/nativeRuntimeService.ts | 16 ++++++++++-- backend/services/turnSandboxService.ts | 7 +++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js index 506d318596..79e057a0e9 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js @@ -98,4 +98,30 @@ describe('native turn with commonly_run_code', () => { 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/); + }); }); diff --git a/backend/__tests__/unit/services/turnSandboxService.test.js b/backend/__tests__/unit/services/turnSandboxService.test.js index 820fdcc0dd..d2db8f2dd7 100644 --- a/backend/__tests__/unit/services/turnSandboxService.test.js +++ b/backend/__tests__/unit/services/turnSandboxService.test.js @@ -36,7 +36,11 @@ describe('TurnSandbox', () => { expect(mockCreate).toHaveBeenCalledTimes(1); const [options] = mockCreate.mock.calls[0]; expect(options).toMatchObject({ - apiKey: 'e2b_test_key', allowInternetAccess: false, envs: {}, metadata: { podId: 'pod-1' }, + apiKey: 'e2b_test_key', + allowInternetAccess: false, + network: { allowPublicTraffic: false }, + envs: {}, + metadata: { podId: 'pod-1' }, }); expect(options.timeoutMs).toBeGreaterThan(0); expect(options.timeoutMs).toBeLessThanOrEqual(5 * 60 * 1000); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 0df03e000d..f64d3b8f3e 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -1100,6 +1100,9 @@ export async function runAgent( } try { + // Tool names offered in the most recent model call; a call to anything + // else is refused at dispatch. + let offeredToolNames = new Set(); // Bounded loop. Each iteration = one LiteLLM call + any tool dispatch. // Break on: no tool_calls (final assistant text), caps hit, or errors. // eslint-disable-next-line no-constant-condition @@ -1128,6 +1131,12 @@ export async function runAgent( const inPodOnly = await inPodOnlyNow(); let llmResponse: LiteLLMResponse; let responseHeaders: Record = {}; + // The allowlist is enforced at dispatch too, not only in what the model + // is offered: a model can name a tool it was never given, and an + // undeclared commonly_run_code (or commonly_propose_action) must not run + // because it was asked for (Rhea on #2232). + const offeredTools = [...toolsForConfig(cfg, inPodOnly), ...broker.tools]; + offeredToolNames = new Set(offeredTools.map((tool: any) => String(tool?.function?.name || ''))); try { const axiosResp = await axios.post( `${litellm.baseUrl}/chat/completions`, @@ -1140,7 +1149,7 @@ export async function runAgent( // `TOOLS`/`toolsForConfig` (wren 73742): it is not the agent's // declared capability, it is a live grant that can be revoked // between runs, and it is assembled once per run above. - tools: [...toolsForConfig(cfg, inPodOnly), ...broker.tools], + tools: offeredTools, tool_choice: 'auto', // Guard the untrusted-user / untrusted-pod-content surface (see the // NATIVE_RUNTIME_GUARDRAILS note above). Omitted entirely when empty @@ -1253,7 +1262,10 @@ export async function runAgent( // Grants can be minted after the turn's tool list was assembled. // Re-read the policy before dispatch; a stale model call is refused. dispatchCtx.inPodOnly = await inPodOnlyNow(); - const result = await dispatchTool(toolName, parsedArgs, dispatchCtx); + const notOffered = `${toolName || 'That tool'} is not available to you in this turn.`; + const result: DispatchResult = offeredToolNames.has(toolName) + ? await dispatchTool(toolName, parsedArgs, dispatchCtx) + : { content: { ok: false, error: notOffered }, error: notOffered }; const elapsed = Date.now() - toolStart; turn.toolCalls.push({ name: toolName, diff --git a/backend/services/turnSandboxService.ts b/backend/services/turnSandboxService.ts index c37b1dd28f..86bdf3fc5f 100644 --- a/backend/services/turnSandboxService.ts +++ b/backend/services/turnSandboxService.ts @@ -7,6 +7,8 @@ * - No internet (`allowInternetAccess: false`). The model writing this code * holds pod content, so outbound access would turn any injected instruction * into an exfiltration channel. + * - No public inbound either (`allowPublicTraffic: false`): sandbox URLs need + * a traffic token that only this process holds. * - No environment variables, so no secret is ever inside it. * - E2B's own sandbox lifetime (SANDBOX_LIFETIME_MS) is the backstop: if the * kill fails, the sandbox stops itself, and its billed seconds stay bounded. @@ -71,6 +73,11 @@ export class TurnSandbox { apiKey: process.env.E2B_API_KEY, timeoutMs: SANDBOX_LIFETIME_MS, allowInternetAccess: false, + // E2B serves every sandbox port on a public URL unless told not to; + // with this off, a URL needs the traffic token only this process holds, + // so a server started inside cannot be reached from outside (Rhea on + // #2232). The code-interpreter client sends that token on every call. + network: { allowPublicTraffic: false }, envs: {}, metadata: this.metadata, }) as Promise; From a665f56886a6c8d0a2ad74209fdc38defddccb8b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:12:12 -0700 Subject: [PATCH 3/5] test(sandbox): pin the empty environment exactly, at create and on every run Sprint-review on #2232 (twice): the envs assertion sat inside toMatchObject, where {} is a subset every object satisfies, so envs: process.env at Sandbox.create left the suite green. It is now an exact toEqual({}) at create and a key count on every runCode; both process-env mutations fail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- backend/__tests__/unit/services/turnSandboxService.test.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/__tests__/unit/services/turnSandboxService.test.js b/backend/__tests__/unit/services/turnSandboxService.test.js index d2db8f2dd7..3286f4cb9a 100644 --- a/backend/__tests__/unit/services/turnSandboxService.test.js +++ b/backend/__tests__/unit/services/turnSandboxService.test.js @@ -42,6 +42,11 @@ describe('TurnSandbox', () => { 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: {} })); From 5723087ab4fb0850850c945b78716572df72f157 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:20:07 -0700 Subject: [PATCH 4/5] fix(native): compose the dispatch gate with #2211's narrowing, and price sandbox seconds After rebasing onto #2211 and #2227: - The offered set is built with #2211's in-pod narrowing, and dispatch keeps both checks: a tool the narrowing removed goes to dispatchTool, which refuses it naming the grant; any other tool not offered this turn is refused by the offer gate. - The spend alarm prices AgentRun.sandboxSeconds at E2B's default-sandbox rate, once per run (summed apart from the per-turn unwind), so a vendor with no spending cap no longer records usage nothing prices (sprint-review on #2232). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- .../nativeRuntimeService.runCode.test.js | 4 +++ .../unit/services/spendAlarmService.test.js | 24 ++++++++++++++- backend/services/nativeRuntimeService.ts | 5 +++- backend/services/spendAlarmService.ts | 30 ++++++++++++++++++- docs/runbooks/alerting.md | 2 +- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js index 79e057a0e9..2c5287994e 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.runCode.test.js @@ -17,6 +17,10 @@ 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', () => ({ diff --git a/backend/__tests__/unit/services/spendAlarmService.test.js b/backend/__tests__/unit/services/spendAlarmService.test.js index 86e9444954..2d5eebee22 100644 --- a/backend/__tests__/unit/services/spendAlarmService.test.js +++ b/backend/__tests__/unit/services/spendAlarmService.test.js @@ -80,6 +80,28 @@ 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); + expect(a.usd).toBeCloseTo(100_000 * spend.SANDBOX_USD_PER_SECOND, 6); + expect(a.runs).toBe(1); + expect(rows.find((r) => r.userId === String(USER_B)).sandboxSeconds).toBe(10); + }); +}); + describe('checkSpend', () => { const alarmLines = (warn) => warn.mock.calls .map(([line]) => { try { return JSON.parse(line); } catch (_) { return null; } }) @@ -98,7 +120,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, }); }); diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index f64d3b8f3e..451b262627 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -1262,8 +1262,11 @@ export async function runAgent( // Grants can be minted after the turn's tool list was assembled. // Re-read the policy before dispatch; a stale model call is refused. dispatchCtx.inPodOnly = await inPodOnlyNow(); + // A tool the narrowing removed goes to dispatchTool, which refuses it + // naming the grant; any other tool not offered this turn is refused here. + const narrowedAway = Boolean(dispatchCtx.inPodOnly) && !IN_POD_NATIVE_TOOLS.has(toolName); const notOffered = `${toolName || 'That tool'} is not available to you in this turn.`; - const result: DispatchResult = offeredToolNames.has(toolName) + const result: DispatchResult = offeredToolNames.has(toolName) || narrowedAway ? await dispatchTool(toolName, parsedArgs, dispatchCtx) : { content: { ok: false, error: notOffered }, error: notOffered }; const elapsed = Date.now() - toolStart; diff --git a/backend/services/spendAlarmService.ts b/backend/services/spendAlarmService.ts index ff51dfe13a..7ac09d1561 100644 --- a/backend/services/spendAlarmService.ts +++ b/backend/services/spendAlarmService.ts @@ -44,6 +44,11 @@ export const priceForModel = (model: string | null | undefined): ModelPrice => ( PRICES.find((row) => row.match.test(String(model || '')))?.price || UNPRICED_MODEL ); +// E2B's default sandbox (2 vCPU, 512 MiB), per second it runs: e2b.dev/pricing, +// read 2026-10-15 (~$0.00037 for a 20 s turn). E2B has no spending cap, so the +// seconds each turn records (AgentRun.sandboxSeconds, TASK-266) are priced here. +export const SANDBOX_USD_PER_SECOND = 0.00003025; + export const spendThresholdUsd = (): number => { const raw = Number(process.env.SPEND_ALARM_USD_PER_DAY); return Number.isFinite(raw) && raw > 0 ? raw : 2; @@ -55,6 +60,7 @@ export interface UserSpend { runs: number; promptTokens: number; completionTokens: number; + sandboxSeconds: number; } const utcDayStart = (now: Date): Date => { @@ -95,6 +101,17 @@ export const userSpendForDay = async (now: Date = new Date()): Promise }>(); for (const row of rows) { const userId = String(row._id.userId); @@ -102,7 +119,7 @@ export const userSpendForDay = async (now: Date = new Date()): Promise(), + userId, usd: 0, runs: 0, promptTokens: 0, completionTokens: 0, sandboxSeconds: 0, runIds: new Set(), }; entry.usd += usd; entry.promptTokens += row.promptTokens; @@ -110,6 +127,16 @@ export const userSpendForDay = async (now: Date = new Date()): Promise entry.runIds.add(String(id))); byUser.set(userId, entry); } + for (const row of sandboxRows) { + const userId = String(row._id); + const entry = byUser.get(userId) || { + userId, usd: 0, runs: 0, promptTokens: 0, completionTokens: 0, sandboxSeconds: 0, runIds: new Set(), + }; + entry.sandboxSeconds += row.seconds; + entry.usd += row.seconds * SANDBOX_USD_PER_SECOND; + row.runs.forEach((id) => entry.runIds.add(String(id))); + byUser.set(userId, entry); + } return [...byUser.values()].map(({ runIds, ...spend }) => ({ ...spend, runs: runIds.size })); }; @@ -138,6 +165,7 @@ export const checkSpend = async (now: Date = new Date()): Promise = runs: spend.runs, promptTokens: spend.promptTokens, completionTokens: spend.completionTokens, + sandboxSeconds: spend.sandboxSeconds, })); } return fresh; diff --git a/docs/runbooks/alerting.md b/docs/runbooks/alerting.md index 4140fd1e9c..9947494a19 100644 --- a/docs/runbooks/alerting.md +++ b/docs/runbooks/alerting.md @@ -237,7 +237,7 @@ user, the amount and the token split. It reports and never blocks. The per-user run ceiling and the per-install caps are what stop runs. Hosted turns and the E2B sandbox have no provider-side spending cap, so this alert is the warning before the bill. -Sandbox seconds join the sum when TASK-266 records them on the run. +Sandbox seconds (`AgentRun.sandboxSeconds`, TASK-266) are in the sum, priced at E2B's default sandbox rate (`SANDBOX_USD_PER_SECOND`). When it fires: From bed02e7a4c86ffe74af8d381c0408e54bffff40f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:31:29 -0700 Subject: [PATCH 5/5] fix(spend): price the sandbox from E2B's unit rates at the shape actually used, and pin the template Rhea's HOLD on #2232 (5536458866): the rate assumed 2 vCPU / 512 MiB, but the code-interpreter template is larger, and her probe showed $1.30 of model spend plus 20,000 sandbox seconds pricing at $1.905 (no alarm) against $2.04 at the published shape. The template is now pinned (code-interpreter-v1) and the rate is derived from E2B's per-vCPU-second and per-GiB-second prices at 2 vCPU / 4 GiB, E2B's stated default and the larger shape on record, so the alarm errs high ($0.000046/s). The new arms use literal, hand-worked amounts on both sides of the $2 line, so a wrong rate fails them; the old 512 MiB rate fails three. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015NDyNbwmCco62PAAviSL1k --- .../unit/services/spendAlarmService.test.js | 24 ++++++++++++++++++- .../unit/services/turnSandboxService.test.js | 3 ++- backend/services/spendAlarmService.ts | 16 +++++++++---- backend/services/turnSandboxService.ts | 6 ++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/backend/__tests__/unit/services/spendAlarmService.test.js b/backend/__tests__/unit/services/spendAlarmService.test.js index 2d5eebee22..4bba763d42 100644 --- a/backend/__tests__/unit/services/spendAlarmService.test.js +++ b/backend/__tests__/unit/services/spendAlarmService.test.js @@ -96,12 +96,34 @@ describe('sandbox seconds (TASK-266)', () => { const rows = await spend.userSpendForDay(NOW); const a = rows.find((r) => r.userId === String(USER_A)); expect(a.sandboxSeconds).toBe(100_000); - expect(a.usd).toBeCloseTo(100_000 * spend.SANDBOX_USD_PER_SECOND, 6); + // 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; } }) diff --git a/backend/__tests__/unit/services/turnSandboxService.test.js b/backend/__tests__/unit/services/turnSandboxService.test.js index 3286f4cb9a..053367411e 100644 --- a/backend/__tests__/unit/services/turnSandboxService.test.js +++ b/backend/__tests__/unit/services/turnSandboxService.test.js @@ -34,7 +34,8 @@ describe('TurnSandbox', () => { expect(first).toEqual({ ok: true, stdout: '4\n', stderr: '', result: '4' }); expect(mockCreate).toHaveBeenCalledTimes(1); - const [options] = mockCreate.mock.calls[0]; + 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, diff --git a/backend/services/spendAlarmService.ts b/backend/services/spendAlarmService.ts index 7ac09d1561..eb47e95870 100644 --- a/backend/services/spendAlarmService.ts +++ b/backend/services/spendAlarmService.ts @@ -44,10 +44,18 @@ export const priceForModel = (model: string | null | undefined): ModelPrice => ( PRICES.find((row) => row.match.test(String(model || '')))?.price || UNPRICED_MODEL ); -// E2B's default sandbox (2 vCPU, 512 MiB), per second it runs: e2b.dev/pricing, -// read 2026-10-15 (~$0.00037 for a 20 s turn). E2B has no spending cap, so the -// seconds each turn records (AgentRun.sandboxSeconds, TASK-266) are priced here. -export const SANDBOX_USD_PER_SECOND = 0.00003025; +// The sandbox's price per second it runs. E2B bills by resource: +// $0.000014 per vCPU-second and $0.0000045 per GiB-second (e2b.dev/pricing, +// read 2026-10-15). The template is pinned (turnSandboxService +// SANDBOX_TEMPLATE); its shape is priced at 2 vCPU and 4 GiB, E2B's stated +// default and the larger of the shapes on record for it (Rhea cites 2 GiB for +// the code-interpreter build), so the alarm errs high. E2B has no spending cap, +// so the seconds each turn records (AgentRun.sandboxSeconds) are priced here. +const E2B_USD_PER_VCPU_SECOND = 0.000014; +const E2B_USD_PER_GIB_SECOND = 0.0000045; +const SANDBOX_SHAPE = { vcpu: 2, gib: 4 }; +export const SANDBOX_USD_PER_SECOND = SANDBOX_SHAPE.vcpu * E2B_USD_PER_VCPU_SECOND + + SANDBOX_SHAPE.gib * E2B_USD_PER_GIB_SECOND; export const spendThresholdUsd = (): number => { const raw = Number(process.env.SPEND_ALARM_USD_PER_DAY); diff --git a/backend/services/turnSandboxService.ts b/backend/services/turnSandboxService.ts index 86bdf3fc5f..be121fde2e 100644 --- a/backend/services/turnSandboxService.ts +++ b/backend/services/turnSandboxService.ts @@ -16,6 +16,10 @@ * seconds recorded on the AgentRun. */ +// Pinned, not left to the SDK's default: the spend alarm prices this shape +// (services/spendAlarmService.ts), and an SDK upgrade must not change it +// silently. code-interpreter 2.9.0's default is the same template. +export const SANDBOX_TEMPLATE = 'code-interpreter-v1'; const SANDBOX_LIFETIME_MS = 3 * 60 * 1000; const RUN_TIMEOUT_MS = 30_000; const REQUEST_TIMEOUT_MS = 45_000; @@ -69,7 +73,7 @@ export class TurnSandbox { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { Sandbox } = require('@e2b/code-interpreter'); this.startedAt = Date.now(); - this.creating = Sandbox.create({ + this.creating = Sandbox.create(SANDBOX_TEMPLATE, { apiKey: process.env.E2B_API_KEY, timeoutMs: SANDBOX_LIFETIME_MS, allowInternetAccess: false,