Repository navigation
Add code mode: isolated code execution over MCP tools - #1366
Conversation
Code mode lets an agent run a short JS program that orchestrates several MCP
tool calls in one round trip, instead of the model making dozens. Off by
default (no CODE_RUNNER_URL, per-server codeMode 'off').
- deploy/code-runner: an isolated container that runs one Deno subprocess per
run under an unprivileged uid with every permission denied, an import map
that blocks every file/node/npm/remote specifier, a cleared environment and
no egress. The only way out is the amcp bridge back to the backend.
Containment + bridge verified by deploy/code-runner/test.sh.
- CodeExecutorService: backend WebSocket client that drives a run and answers
the bridge via executeTool under the caller's scope; refuses when no runner
is configured, never runs code in-process.
- Saved code tools: McpTool endpointMapping.method = 'code' ({ code, uses,
runAs, allowWrites }). A bridge enforces the uses allow-list, read vs write,
invoker vs definer role and no nested code tools; cross-tenant uses are
rejected at save time, definer is admin-only, allowWrites is Enterprise.
- Schema (migration 20261031120000_code_mode): roles.capabilities,
mcp_server_configs.code_mode, kg_skill_suggestions.kind, and the code_runs
audit table. The per-server codeMode switch is enforced and a code_runs row
is written per run.
- Tool editor gains a Code tool type (en/it/de).
Refs #1361 #1362 #1363
- code_execute: a per-server tool that runs an ad-hoc program through the runner, read-only, with the caller's own permissions, over the connectors they can reach. Offered only when the server's codeMode is 'inline', a runner is configured, and the caller's role carries the inlineCode capability. - DynamicMcpTools.executeInlineCode shares the bridge builder with saved code tools; writes an inline code_runs row (no McpTool, so no outer invocation row). - RolesService.getCapabilities resolves the union of a caller's roles' capabilities from the authoritative org membership; ADMIN holds all. - The per-server endpoint registers code_execute (stateless and on session rebuild) when resolveInlineCode permits it. Refs #1364
LicenseGuardService.checkCanCreateCodeTool enforces features.maxCodeTools as a hard cap on the Cloud trial and a soft limit elsewhere, like connectors and MCP servers; the per-plan number lives on the licensing site, not in code. Self-hosted is never capped. Enforced at tool creation for code tools; writes (Enterprise) and definer rights (admin) are already gated. Refs #1365
Wires the real CodeExecutorService and the real code-runner container as one system: a code tool orchestrates two tools in the sandbox and the program sums their results, and an in-sandbox fetch is contained. Opt-in via RUN_CODE_RUNNER_LIVE=1 + CODE_RUNNER_URL (skipped by default so CI needs no Docker). Refs #1361 #1363
| if (finished) return; | ||
| } | ||
| // stdin closed before we produced a result. | ||
| if (!finished) await finish({ ok: false, error: { name: 'Error', message: 'input stream closed before the run completed' } }); |
The code-runner supervisor (PID 1) must run as root to setpriv down to an unprivileged uid per run, so the image has no USER instruction. The container drops all capabilities except SETUID/SETGID, is read-only and no-new-privileges, and the untrusted code runs as uid 10001. Add a .trivyignore waiving AVD-DS-0002 with that rationale (path-scoped misconfig ignores are not honoured by the CI Trivy build; the other Dockerfiles already declare a non-root USER).
Local end-to-end smoke (code mode)Ran the full runner path on a dev machine (Docker 29.8.2, darwin) against the Container — run with the production hardening from 1. Containment + bridge ( 2. E2E WebSocket (backend → supervisor → setpriv → locked Deno → harness → bridge): 3. Backend live integration ( Nothing to change; recorded here as evidence the live path works on the pinned image. |
Summary
Code mode lets an AI client run a short JavaScript program that orchestrates several of a workspace's MCP tools in one step, instead of making the calls one at a time. It is off by default everywhere: it does nothing until an operator runs the code-runner service (
CODE_RUNNER_URL) and a workspace turns it on per MCP server (codeMode, defaultoff). Merging this changes no existing behaviour.Code is untrusted (a prompt-injected tool result can steer it), so it never runs in the backend process. It runs in a separate container, one locked-down Deno subprocess per run, with no filesystem, network, environment or subprocess access; the only way out is the MCP tools the caller is already allowed to use, through an
amcpbridge the backend answers under that caller's role.Changes
deploy/code-runner/— isolated runner: supervisor (drops privileges per run), harness, an import map that blocks every file/node/npm/remote specifier, pinned Deno image, andtest.sh(containment + bridge).CodeExecutorService— backend WebSocket client; answers the bridge viaexecuteToolunder the caller's scope; refuses when no runner is configured (never runs in-process).McpToolwithendpointMapping.method = 'code'(code,uses,runAs,allowWrites). A bridge enforces the uses allow-list, read-vs-write, invoker-vs-definer role and no nested code tools; save-time gates: definer = admin, allowWrites = Enterprise, uses must be same-workspace. Editor gains a Code tool type (en/it/de).code_execute+ in-sandboxamcp.search/describe/run— per-server, read-only, invoker rights, gated bycodeMode = 'inline'+ theinlineCoderole capability.20261031120000_code_mode) —roles.capabilities,mcp_server_configs.code_mode,kg_skill_suggestions.kind, and thecode_runsaudit table.LicenseGuardService.checkCanCreateCodeToolreadsfeatures.maxCodeTools(hard on the Cloud trial, soft on paid). Code tools are never served on the shared/mcpendpoint.docs/code-mode.mdand the generated llms indexes.Type
Testing
New unit/integration specs (bridge access rules, executor transport, the code-tool and inline branches, role capabilities, controller validation/gates, shared-endpoint exclusion). Verified locally:
tsc0 errors,eslint0 on changed files.connectors/engines/email.engine.integration.spec.ts, which is unrelated to this PR (no email files changed) and passes in isolation; it is an environmental flake under full-suite concurrency.deploy/code-runner/test.sh(19 containment assertions + the end-to-end WebSocket path) on Deno 2.9.8.code-mode.integration.spec.ts, opt-inRUN_CODE_RUNNER_LIVE=1): the realCodeExecutorService+ the real runner container as one system — a code tool orchestrated two tools in the sandbox and returned their sum, and an in-sandboxfetchwas contained.tsc0 errors.Checklist
npm test) — one unrelated email integration test flakes under full-suite concurrency (passes alone); everything else is greenProduction readiness
Off by default, so safe to land for both Cloud and self-hosted. Enabling it is a separate decision:
No
ee/files were changed.Related Issues
Relates to #1361, #1362, #1363, #1364, #1365