Skip to content

Add code mode: isolated code execution over MCP tools - #1366

Merged
helpcodeai merged 7 commits into
mainfrom
helpcodeai/code-mode
Oct 31, 2026
Merged

helpcodeai merged 7 commits into
mainfrom
helpcodeai/code-mode

Conversation

@helpcodeai

Copy link
Copy Markdown
Contributor

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, default off). 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 amcp bridge 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, and test.sh (containment + bridge).
  • CodeExecutorService — backend WebSocket client; answers the bridge via executeTool under the caller's scope; refuses when no runner is configured (never runs in-process).
  • Saved code tools — McpTool with 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; save-time gates: definer = admin, allowWrites = Enterprise, uses must be same-workspace. Editor gains a Code tool type (en/it/de).
  • Inline code_execute + in-sandbox amcp.search/describe/run — per-server, read-only, invoker rights, gated by codeMode = 'inline' + the inlineCode role capability.
  • Schema (migration 20261031120000_code_mode) — roles.capabilities, mcp_server_configs.code_mode, kg_skill_suggestions.kind, and the code_runs audit table.
  • Gating — LicenseGuardService.checkCanCreateCodeTool reads features.maxCodeTools (hard on the Cloud trial, soft on paid). Code tools are never served on the shared /mcp endpoint.
  • Docs — docs/code-mode.md and the generated llms indexes.

Type

  • New feature

Testing

  • Added/updated unit tests
  • Tested manually (describe steps below)

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:

  • Backend tsc 0 errors, eslint 0 on changed files.
  • Full backend suite: 929 suites pass, 4 skipped, 1 failed — 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.
  • Migration applied to a throwaway Postgres 17; diff against the schema is empty.
  • Runner containment + bridge: deploy/code-runner/test.sh (19 containment assertions + the end-to-end WebSocket path) on Deno 2.9.8.
  • Live end-to-end (code-mode.integration.spec.ts, opt-in RUN_CODE_RUNNER_LIVE=1): the real CodeExecutorService + the real runner container as one system — a code tool orchestrated two tools in the sandbox and returned their sum, and an in-sandbox fetch was contained.
  • Frontend tsc 0 errors.

Checklist

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation
  • All existing tests pass (npm test) — one unrelated email integration test flakes under full-suite concurrency (passes alone); everything else is green
  • This PR has a descriptive title

Production readiness

Off by default, so safe to land for both Cloud and self-hosted. Enabling it is a separate decision:

  • Self-hosted (Enterprise): the intended first target; the engine is proven end-to-end. Before a customer-facing beta it still needs a full-stack smoke (a running backend with auth + a real connector) and a security review.
  • Cloud: not yet. Needs the runner wired into the Cloud deployment, monthly run quotas, and a penetration test — tracked as operational work outside this repo (per AGENTS.md).

No ee/ files were changed.

Related Issues

Relates to #1361, #1362, #1363, #1364, #1365

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
The per-server codeMode switch that governs code tools has no equivalent on the
shared endpoint, so serving a code tool there would run code outside that
control. Exclude method 'code' tools from the shared toolset; shared-endpoint
code mode is a separate, Enterprise-gated design.

Refs #1363 #1364
Add docs/code-mode.md (what it is, saved code tools vs inline, the amcp bridge,
governance, deployment, limits) and include it in the generated llms indexes.

Refs #1363 #1364
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
Comment thread deploy/code-runner/Dockerfile Fixed
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).
@helpcodeai

Copy link
Copy Markdown
Contributor Author

Local end-to-end smoke (code mode)

Ran the full runner path on a dev machine (Docker 29.8.2, darwin) against the
PR's code-runner image, built from deploy/code-runner at this branch.

Container — run with the production hardening from docker-compose.yml:
--read-only --tmpfs /tmp/code-runner:mode=1777 --security-opt no-new-privileges=true --cap-drop ALL --cap-add SETUID --cap-add SETGID. Reached healthy.

1. Containment + bridge (deploy/code-runner/test.sh, in-image): 19/19 passed —
compute, bridge_run, bridge_search, console, and every escape vector denied:
deny_fetch, deny_read, deny_env, deny_run, deny_import_text, deny_import_json,
deny_import_local, deny_import_fn, deny_node_fs, deny_node_cp, deny_proc_environ,
deny_ffi, plus timeout / throw / syntax handling.

2. E2E WebSocket (backend → supervisor → setpriv → locked Deno → harness → bridge):
amcp.run("demo_echo", {a:2}) round-trips; program returns 42. ✓

3. Backend live integration (code-mode.integration.spec.ts,
RUN_CODE_RUNNER_LIVE=1, real executor + real container): 2/2 passed —
a code tool orchestrates two tools in the sandbox and returns their sum (42,
SUCCESS, code_runs row written), and a fetch('https://example.com') from
inside a code tool is contained (ERROR, net access denied).

Nothing to change; recorded here as evidence the live path works on the pinned image.

@helpcodeai
helpcodeai merged commit be4742a into main Oct 31, 2026
14 checks passed
@helpcodeai
helpcodeai deleted the helpcodeai/code-mode branch October 31, 2026 15:39
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants